# Overview What PLEX is, how an app is made of plugins, and Composer as the app built on it. PLEX is an agentic framework for building apps out of plugins. An app is little more than the plugins it loads: each plugin adds types, views, actions and settings, and plugins extend each other through typed contributions instead of imports. ## How an app is put together A plugin is a set of modules. Each module activates on an event, such as startup or the first time a view is needed, and contributes capabilities: typed values such as a React component for a surface, a handler for an operation, or nodes for the navigation graph. Other modules look capabilities up, so one plugin can render inside another plugin's view or add an action to another plugin's objects without either knowing about the other. [Plugins and modules](/docs/plex/concepts/plugins/) explains the pieces. The packages are `@dxos/app-framework`, which holds plugins, modules, capabilities and surfaces, and `@dxos/app-toolkit`, which adds what a workspace app needs: App Graph, layout, settings and the standard surface roles. ## Composer: a super-app built on PLEX [Composer](/composer) is a local-first workspace built entirely with PLEX. Its navigation tree, the deck of open items, the settings page and the assistant are all plugins, written against the same API a third-party plugin uses. A plugin from outside DXOS has the same powers as a built-in one, which is why these pages use Composer as the place your plugin runs. ## What ECHO does underneath ECHO is the database under every PLEX app. A plugin's data is ECHO objects in spaces, so it syncs between a user's devices and the people they share with, merges concurrent edits, and works offline, without the plugin doing anything for it. Agents read and write the same objects through the same queries. [Spaces](/docs/echo/concepts/spaces/) and [Objects and relations](/docs/echo/concepts/objects-relations/) explain the model. ## What you can build The four demos below are small PLEX apps built from Composer's own plugins. Each runs two peers, Alice and Bob, on one space in the page. The text above each demo says which plugins render it and how they work together. Use the switch on a peer to take it offline, or the toolbar to show one peer or both. These are examples, not the limits. Composer's own navigation, settings and assistant are plugins too, so a plugin of your own can add anything a built-in one can: new types and views, actions, widgets inside another plugin's editor, or a whole new kind of app. ## Where to go next - [Why PLEX?](/docs/plex/why-plex/) for what an app made of plugins gives you. - [Build with a coding agent](/docs/plex/build-with-a-coding-agent/) to have Claude Code, Codex or Cursor build a plugin and load it into your Composer. - [Create a plugin](/docs/plex/learn/create-plugin/) to start the learning path, which builds the Stash plugin by hand over five pages. --- # Why PLEX? Why an app made of plugins, where a third-party plugin has the same powers as a built-in one. Most apps have a fixed core that the app's team writes. Code from anyone else gets only the hooks the team chose to expose, and two such add-ons rarely work with each other. A PLEX app is the plugins it loads. The navigation tree, the settings page and the assistant are plugins too, so a plugin from outside the team uses the same API as a built-in one. ## The pattern A plugin says which object type it renders and where. The app asks for "the article for this object" and renders whatever plugin answers, without importing it. ```ts import * as Surface from '@dxos/app-framework/Surface'; import * as AppSurface from '@dxos/app-toolkit/AppSurface'; export const NoteArticleSurface = Surface.create({ id: 'article', // Matches the article role when the data's subject is a Note. filter: AppSurface.object(AppSurface.Article, Note), component: NoteArticle, // Maps the surface's data onto the component's props; checked against the // filter's data type. props: ({ role, data: { subject, attendableId } }) => ({ role, subject, attendableId, }), }); ``` ## A fixed core against an app of plugins | | App with a fixed core | PLEX app | | --------------------------------- | -------------------------------------- | ---------------------------------------------------------------- | | What the core is | Code the app's team writes | The plugins it loads, including the core UI | | What outside code can change | Only the hooks the team exposes | Anything a built-in plugin can, through the same capabilities | | How two add-ons work together | Through the host, if it allows it | One plugin reads another's capabilities and renders its surfaces | | When outside code loads | Usually at startup | Each module when its activation event fires | | How an agent acts | A separate API, if there is one | Through the same operations the UI runs | | Where outside code keeps its data | A store of its own | ECHO objects in spaces, which every plugin can query | ## Practical problems ### Extending a plugin you did not write Plugins extend each other through capabilities: typed slots that one module contributes to and another reads. A plugin can render inside another plugin's view by contributing a surface for a role, or add an action to another plugin's objects by adding nodes to App Graph. Neither plugin imports the other. [Surfaces](/docs/plex/concepts/surfaces/) and [App Graph](/docs/plex/guides/app-graph/) explain both. ### Loading only what is used An app with many plugins cannot load all of them at startup. Each module waits for an activation event, such as the first time a view is needed. A surface module loads the first time one of its roles is requested, and an operation handler loads the first time the operation runs. [ActivationEvent](/docs/plex/guides/activation-event/) lists the events. ### One action for people and agents An operation is defined once, with a typed input and output. A button invokes it, the command palette lists it, and the assistant calls it as a tool, reading the schema to fill in the input. None of the three needs code of its own. [Operations](/docs/plex/concepts/operations/) explains how. ### Data that syncs without plugin code A plugin's data is ECHO objects in spaces. It syncs between a user's devices and the people they share with, merges concurrent edits and works offline, with no sync code in the plugin. [Spaces](/docs/echo/concepts/spaces/) explains the model. ## What PLEX does not do PLEX does not store or sync data; ECHO does. It is not a component library either: plugins build their views with `@dxos/react-ui`, or with any React components. Start with [Create a plugin](/docs/plex/learn/create-plugin/). --- # Build with a coding agent Have Claude Code, Codex or Cursor build a plugin and load it into your Composer, from a prompt you can edit first. A coding agent can build a plugin in a folder of yours and load it into Composer, in a few minutes. Before you start: 1. Sign in to [Composer Preview](https://preview.composer.space) and keep it open. Only Composer Preview loads plugins from a dev server; composer.space loads only the plugins it ships with. 2. Open an empty folder in your agent. The plugin is its own project, and the agent sets it up there. Then describe the plugin you want in the prompt below, or pick an example, and open it in your agent. export const examples = [ { label: 'World clock', prompt: "Create a Composer plugin called World Clock. For each space it should keep a list of timezones, shown as a map with a pin for each above a row of ticking clocks, so the team can see everyone's local time.", }, { label: 'Sankey chart', prompt: 'Create a Composer plugin called Sankey. It should add a Sankey chart type to sheet charts: a chart of a range whose columns are from, to and amount draws the flows between them, so a budget shows how income flows into spending categories.', }, { label: 'Receipts filter', prompt: 'Create a Composer plugin called Receipts. It should add a Find receipts action to the mailbox toolbar that tags every receipt or invoice in the mailbox and reads its total, and a Receipts page for each mailbox that lists them and charts spending per month.', }, { label: 'QR code canvas tool', prompt: 'Create a Composer plugin called QR Code. It should add a QR code tool to the canvas palette: the element holds a URL or text, draws a QR code for it that a phone can scan, and redraws when the text changes.', }, { label: 'Tournament bracket', prompt: 'Create a Composer plugin called Tournament Bracket. It should run a tournament for any game in Composer: given players and a game, it builds a bracket where each match is a game that opens and plays like any other. When a match ends, the winner moves to the next round.', }, ]; ```text Use the composer-plugin-dev skill: https://raw.githubusercontent.com/dxos/dxos/main/tools/claude/plugins/dxos/skills/composer-plugin-dev/SKILL.md ``` The `composer-plugin-dev` skill tells the agent how to set up the project, which DXOS packages to use, and how to load the plugin into your Composer. The prompt installs it in the folder first, so you do not need to install anything. ## When the agent hands off The agent builds the plugin and starts a dev server for it. Then it asks you to load it: 1. In Composer Preview, open **Settings**, then **Plugins**. 2. Click **Enable** under **Dev Server**. 3. Allow the browser's prompt to reach devices on your local network. 4. Work through the checklist the agent gives you, and tell it what you see. The plugin loads from the agent's dev server, so it is there only while the server runs. To keep it, [Publishing a plugin](/docs/plex/publishing-plugins/) puts it in your private registry. To build one yourself instead, start with [Create a plugin](/docs/plex/learn/create-plugin/). --- # The PLEX plugin What a plugin is, and the plugin the learning path builds, running in the plugin inspector. The learning path builds one plugin: Stash, a reading list like Pocket: each stashed link has an address, a note on why it is worth reading, and whether you have read it. This page explains what a plugin is made of, and shows Stash finished. ## What a plugin is A PLEX plugin is metadata and a set of modules. The metadata names the plugin and describes it in the registry. Each module waits for an activation event, such as the app starting or a view first needing it, and then contributes capabilities: typed values that the rest of the app looks up. Strings, types, views, operations and entries in the navigation tree are all capabilities. A module's code loads only when it activates, so a plugin costs nothing until it is used. Composer is built this way: every feature in it is a plugin, and your plugin uses the same APIs. [Plugins and modules](/docs/plex/concepts/plugins/) describes the parts in full. ## The finished plugin Below, Stash runs in the plugin inspector, a small PLEX app that loads one plugin and shows each of its modules working. The sidebar lists the modules. Click **New** in the header to create a sample link, then select **Operations** to mark it as read with **Mark as read**, **App Graph** to see where links sit in the navigation tree, and **Surface** to see a link's view. Each page of the learning path builds one piece of it, and ends with that piece in the inspector: | Page | Files | What it adds | | --- | --- | --- | | [Create a plugin](/docs/plex/learn/create-plugin/) | `dx.config.ts`, `translations.ts` | The plugin's metadata and its strings | | [Define a type](/docs/plex/learn/define-type/) | `types/StashedLink.ts`, `capabilities/schema.ts` | The Stashed link type | | [Add an operation](/docs/plex/learn/add-operation/) | `types/StashedLinkOperation.ts`, `operations/`, `capabilities/operation-handler.ts`, `capabilities/create-object.ts` | A way to create a link from the + menu, and Mark as read | | [Extend the App Graph](/docs/plex/learn/extend-app-graph/) | `capabilities/app-graph-builder.ts` | A Stashed links section in the navigation tree, with Mark as read on each link's toolbar | | [Add a surface](/docs/plex/learn/add-surface/) | `containers/StashedLinkArticle.tsx`, `capabilities/react-surface.ts` | The view that edits a link's title, address and note, and shows whether it has been read | | [Run it in Composer](/docs/plex/learn/run-in-composer/) | `skills/`, `capabilities/skill-definition.ts` | The plugin in Composer, and the skill that gives Mark as read to the assistant | From the next page on, the inspector on each page loads your own plugin from its dev server, and checks what that page adds. --- # Create a plugin Start the tutorial plugin with its metadata, its first module and its translations. 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 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](/docs/plex/learn/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. ## Steps 1. **Create the package.** Make a directory for the plugin with a `package.json` and a `vite.config.ts`. Then install the dependencies in that directory: ```bash 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](/docs/plex/publishing-plugins/) 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. ```ts import * as Plugin from '@dxos/app-framework/Plugin'; export const StashNamespace = Plugin.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. ```ts import * as Plugin from '@dxos/app-framework/Plugin'; import * as AppCapability from '@dxos/app-toolkit/AppCapability'; import { StashNamespace } from './src/namespace.ts'; const config: Plugin.PluginConfig = Plugin.config({ plugin: { key: StashNamespace.key, name: 'Stash', author: 'Example', description: 'A reading list: save links with a note, and mark them as read.', icon: { key: 'ph--tray-arrow-down--regular', hue: 'indigo' }, tags: ['labs'], }, modules: [ // #region translations AppCapability.translations( () => import('./src/translations.ts', { with: { type: 'module-url' } }), ), ``` 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. ```ts import { Type } from '@dxos/echo'; import { StashNamespace } from './namespace.ts'; import { StashedLink } from './types/StashedLink.ts'; export const translations = [ { 'en-US': { [StashNamespace.key]: { 'plugin.name': 'Stash', 'mark-read.label': 'Mark as read', }, // #region type [Type.getTypename(StashedLink)]: { 'typename.label': 'Stashed link', 'typename.label_zero': 'Stashed links', 'typename.label_one': 'Stashed link', 'typename.label_other': 'Stashed links', 'object-name.placeholder': 'New link', 'add-object.label': 'Add link', }, ``` 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. ```ts AppCapability.translations( () => import('./src/translations.ts', { with: { type: 'module-url' } }), ), ``` 5. **Load it in the inspector.** Start the dev server in the plugin's directory with the package's `dev` script: ```bash 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. ## 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. --- # Define a type Give the plugin its own ECHO type. By the end of this page the plugin has a Stashed link type, and the inspector lists its fields. ## Background A plugin's data is ECHO objects, and a type describes them. The app reads the type's annotations to name an object, pick its icon, and decide whether a person can create one. [Defining types](/docs/echo/learn/defining-types/) and the [Type](/docs/echo/api/type/) guide cover types in full; this page uses only what PLEX needs. ## Steps 1. **Declare the type.** `Type.makeObject` gives the schema a DXN and makes it an ECHO type. The annotations tell the app which field is the object's label, which icon to show, and that the type is one a person creates (`UserType`). The fields are optional because a new object starts empty. ```ts import * as Schema from 'effect/Schema'; import { Annotation, DXN, Type } from '@dxos/echo'; export class StashedLink extends Type.makeObject( DXN.make('com.example.type.stashedLink', '0.1.0'), )( Schema.Struct({ title: Schema.optional(Schema.String.annotate({ title: 'Title' })), url: Schema.optional(Schema.String.annotate({ title: 'URL' })), note: Schema.optional(Schema.String.annotate({ title: 'Note' })), read: Schema.optional(Schema.Boolean.annotate({ title: 'Read' })), }).pipe( Annotation.LabelAnnotation.set(['title']), Annotation.IconAnnotation.set({ icon: 'ph--tray-arrow-down--regular', hue: 'indigo', }), Annotation.UserType.set(), ), ) {} ``` 2. **Add a factory.** `make` fills in defaults, so every caller creates a link the same way. ```ts import { Obj } from '@dxos/echo'; export const make = ( props: Partial> = {}, ): StashedLink => Obj.make(StashedLink, { title: props.title, url: props.url, note: props.note ?? '', read: props.read ?? false, }); ``` 3. **Register the type with the app.** The schema module tells the app the type exists, so it can query, store and name objects of it. ```ts import { StashedLink } from '../types/StashedLink.ts'; export default [StashedLink]; ``` 4. **Name the type.** The app labels a type with strings from the type's own namespace, its typename. Add an entry for it to the translations, after the plugin's own. ```ts [Type.getTypename(StashedLink)]: { 'typename.label': 'Stashed link', 'typename.label_zero': 'Stashed links', 'typename.label_one': 'Stashed link', 'typename.label_other': 'Stashed links', 'object-name.placeholder': 'New link', 'add-object.label': 'Add link', }, ``` 5. **Add the module to the manifest.** ```ts AppCapability.schema( () => import('./src/capabilities/schema.ts', { with: { type: 'module-url' }, }), ), ``` 6. **See the type.** Click **Load dev plugin** below, or **Reload** if your plugin is loaded already, and select **Schema**. It lists the Stashed link type with its fields. There is no way to create a link yet. Creating one runs an operation, and the next page adds operations. ## What you can do now - Declare a type with the annotations the app reads. - Register the type so the app can store and name it. - Name the type in the plugin's translations. --- # Add an operation Give the plugin an action that the UI, the command palette and agents can run, and create objects through one. By the end of this page the **+** menu creates links through an operation, and a **Mark as read** operation marks a link as read. ## Background An operation is a named action with a typed input and output. The plugin defines it once and contributes a handler. After that, a button, the command palette and the assistant all run it the same way, through the framework's operation invoker. [Operations](/docs/plex/concepts/operations/) explains why. Creating an object is an operation too. The Space plugin defines `SpaceOperation.AddObject`, and a plugin uses it to put a new object in a space. ## Steps 1. **Define an operation.** The key is a DXN. The input and output are schemas; an agent reads their descriptions to learn what to pass. The input is a reference to a link rather than the link itself, so the assistant can pass one by its id. ```ts import * as Schema from 'effect/Schema'; import * as Operation from '@dxos/compute/Operation'; import { Database, DXN, Ref } from '@dxos/echo'; import { StashedLink } from './StashedLink.ts'; export const MarkRead = Operation.make({ meta: { key: DXN.make('com.example.operation.stashedLink.markRead'), name: 'Mark as read', description: 'Mark a link as read.', icon: 'ph--check-circle--regular', }, // The handler resolves the reference in the link's space. services: [Database.Service], input: Schema.Struct({ link: Ref.Ref(StashedLink).annotate({ description: 'The link that was read.', }), }), output: Schema.Void, }); ``` 2. **Implement the handler.** `Operation.withHandler` attaches an Effect to the definition. Operations are built on [Effect](https://effect.website/), and `Effect.gen` reads like an async function, with `yield*` in place of `await`. This one looks the reference up in the space's database with `Database.resolve` and sets the link's `read` field. The operation's `services` list what the handler uses from the context, here the space's database. ```ts import * as Effect from 'effect/Effect'; import * as Operation from '@dxos/compute/Operation'; import { Database, Obj } from '@dxos/echo'; import { StashedLink } from '../types/StashedLink.ts'; import { MarkRead } from '../types/StashedLinkOperation.ts'; const handler: Operation.WithHandler = MarkRead.pipe( Operation.withHandler(({ link: ref }) => Effect.gen(function* () { // The assistant passes a reference by id, so look it up in the space. const link = yield* Database.resolve(ref, StashedLink); Obj.update(link, (link) => { link.read = true; }); }), ), ); export default handler; ``` 3. **Contribute the handler.** A handler set lists the plugin's handlers. `Operation.lazyHandler` loads the handler's file the first time the operation runs, not when the plugin starts. ```ts import * as Operation from '@dxos/compute/Operation'; import * as OperationHandlerSet from '@dxos/compute/OperationHandlerSet'; import { MarkRead } from '../types/StashedLinkOperation.ts'; export const StashOperationHandlerSet = OperationHandlerSet.lazy([ MarkRead.pipe(Operation.lazyHandler(() => import('./mark-read.ts'))), ]); ``` ```ts import * as Effect from 'effect/Effect'; import * as Capabilities from '@dxos/app-framework/Capabilities'; import * as Capability from '@dxos/app-framework/Capability'; import { StashOperationHandlerSet } from '../operations/index.ts'; export default Capability.makeModule(() => Effect.succeed( Capability.contribute( Capabilities.OperationHandler, StashOperationHandlerSet, ), ), ); ``` 4. **Create a link through an operation.** The create-object module adds the type to the **+** menu and says how to create one: it invokes `SpaceOperation.AddObject` with a new link from `make`. Invoking the operation, rather than adding to the database directly, puts the link wherever the person asked, such as inside a collection. ```ts import * as Effect from 'effect/Effect'; import * as Capability from '@dxos/app-framework/Capability'; import * as Operation from '@dxos/compute/Operation'; import { Type } from '@dxos/echo'; import * as SpaceCapabilities from '@dxos/plugin-space/SpaceCapabilities'; import * as SpaceOperation from '@dxos/plugin-space/SpaceOperation'; import { StashedLink, make } from '../types/StashedLink.ts'; export default Capability.makeModule(() => Effect.succeed( Capability.contribute(SpaceCapabilities.CreateObjectEntry, { id: Type.getTypename(StashedLink), createObject: (props, options) => Operation.invoke( SpaceOperation.AddObject, { object: make(props), target: options.target }, { spaceId: options.db.spaceId }, ), }), ), ); ``` 5. **Add the modules to the manifest.** ```ts AppCapability.operationHandler( () => import('./src/capabilities/operation-handler.ts', { with: { type: 'module-url' }, }), ), SpaceCapability.createObject( () => import('./src/capabilities/create-object.ts', { with: { type: 'module-url' }, }), ), ``` 6. **Run it.** Load or reload your plugin below. Click **New** in the header: the inspector runs the create-object module, as Composer's **+** menu would, and a link named "New link" becomes the sample object that every module is shown with. Select **Operations**. The form is built from the operation's input schema, and its **link** field takes the sample. Click **Run**: the operation has no output, so the inspector shows the sample after it ran, with `read` set to `true`. The links have no view yet. The next page lists them in the navigation tree and puts Mark as read on each link's toolbar, and the page after that adds a view. ## What you can do now - Define an operation with schemas for its input and output, taking an object by reference. - Implement a handler and load it lazily. - Create an object of the plugin's type through the Space plugin's operation. --- # Extend the App Graph Put the plugin's objects and actions into the app's navigation. By the end of this page each space has a **Stashed links** section under **Content** in the navigation tree, and every link has **Mark as read** on its toolbar and in its menu in the tree. ## Background A PLEX app draws its navigation tree, toolbars and menus from one graph, App Graph. In Composer that is the tree on the left, the object toolbar and the context menus. Its nodes are the things a person can open, and its action nodes are what they can do to them. Plugins add to the graph with builder extensions: functions that match a node and return its children or its actions. The app runs them only for the nodes a person expands. [App Graph](/docs/plex/guides/app-graph/) explains the model. ## Steps 1. **Start the module's file.** The extensions on this page go in `capabilities/app-graph-builder.ts`, with these imports. ```ts import * as Effect from 'effect/Effect'; import * as Capability from '@dxos/app-framework/Capability'; import * as AppGraphBuilder from '@dxos/app-graph/AppGraphBuilder'; import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities'; import * as AppNodeMatcher from '@dxos/app-toolkit/AppNodeMatcher'; import * as GraphPath from '@dxos/app-toolkit/GraphPath'; import * as TypeSection from '@dxos/app-toolkit/TypeSection'; import * as Operation from '@dxos/compute/Operation'; import { Obj, Ref } from '@dxos/echo'; import { StashNamespace } from '../namespace.ts'; import { StashedLink } from '../types/StashedLink.ts'; import { MarkRead } from '../types/StashedLinkOperation.ts'; import * as Effect from 'effect/Effect'; import * as Capability from '@dxos/app-framework/Capability'; import * as AppGraphBuilder from '@dxos/app-graph/AppGraphBuilder'; import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities'; import * as AppNodeMatcher from '@dxos/app-toolkit/AppNodeMatcher'; import * as GraphPath from '@dxos/app-toolkit/GraphPath'; import * as TypeSection from '@dxos/app-toolkit/TypeSection'; import * as Operation from '@dxos/compute/Operation'; import { Obj, Ref } from '@dxos/echo'; import { StashNamespace } from '../namespace.ts'; import { StashedLink } from '../types/StashedLink.ts'; import { MarkRead } from '../types/StashedLinkOperation.ts'; ``` 2. **Add a section for the type.** The navigation tree groups each space's sections under headings such as Content and System. `TypeSection.createTypeSectionExtension` builds the usual section for an object type: it matches the group you pass, lists the space's links under a **Stashed links** heading named and coloured from the type's annotations, and hides the heading when the space has none. `urlKey` gives each link an address in the app's URL, which is how Composer opens one when a person clicks it. ```ts import * as AppNodeMatcher from '@dxos/app-toolkit/AppNodeMatcher'; import * as GraphPath from '@dxos/app-toolkit/GraphPath'; import * as TypeSection from '@dxos/app-toolkit/TypeSection'; import { StashedLink } from '../types/StashedLink.ts'; /** A "Stashed links" section in each space's Content group, once it has one. */ const section = TypeSection.createTypeSectionExtension(StashedLink, { match: AppNodeMatcher.whenNavTreeGroup(GraphPath.GroupTypes.content), groupSegment: GraphPath.GroupSegments.content, // The link's address in Composer's URL: `/w//stash/`. urlKey: 'stash', }); ``` 3. **Attach the operation as an action.** `createTypeExtension` matches every node whose object is a link, and this extension returns an action instead of children. Its `disposition` says where it shows: `toolbar` puts it on the link's toolbar, and `list-item` in the link's menu in the navigation tree. The command palette lists it either way. Its `data` invokes Mark as read from [Add an operation](/docs/plex/learn/add-operation/) with a reference to the link, in the link's space. ```ts import * as Effect from 'effect/Effect'; import * as AppGraphBuilder from '@dxos/app-graph/AppGraphBuilder'; import * as Operation from '@dxos/compute/Operation'; import { Obj, Ref } from '@dxos/echo'; import { StashNamespace } from '../namespace.ts'; import { StashedLink } from '../types/StashedLink.ts'; import { MarkRead } from '../types/StashedLinkOperation.ts'; /** An action on every link, on its toolbar and its menu in the tree. */ const stashedLinkActions = AppGraphBuilder.createTypeExtension({ id: 'stashedLinkActions', type: StashedLink, actions: (link) => Effect.succeed([ { id: 'markRead', data: () => Operation.invoke( MarkRead, { link: Ref.make(link) }, { spaceId: Obj.getDatabase(link)?.spaceId }, ), properties: { label: ['mark-read.label', { ns: StashNamespace.key }], icon: 'ph--check-circle--regular', disposition: ['toolbar', 'list-item'], }, }, ]), }); ``` 4. **Contribute the extensions.** The module builds both and contributes them together as the App Graph builder capability. ```ts import * as Effect from 'effect/Effect'; import * as Capability from '@dxos/app-framework/Capability'; import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities'; export default Capability.makeModule( Effect.fnUntraced(function* () { const extensions = yield* Effect.all([section, stashedLinkActions]); return Capability.contribute( AppCapabilities.AppGraphBuilder, extensions.flat(), ); }), ); ``` 5. **Add the module to the manifest.** The App Graph maker activates the module when the app is idle after startup. ```ts AppCapability.appGraphBuilder( () => import('./src/capabilities/app-graph-builder.ts', { with: { type: 'module-url' }, }), ), ``` 6. **See the result.** Load or reload your plugin below, create a sample link with **New**, and select **App Graph**. The inspector shows the branches of the graph, as Composer builds it, that lead to what your plugin added, and marks those nodes. A **Stashed links** section appears in the space's Content group, with the link under it, and **Mark as read** is the link's action. ## What you can do now - Add a section for a type to a group in the navigation tree, with an address for each object. - Attach an operation to a type's nodes as an action. Next, [Add a surface](/docs/plex/learn/add-surface/) gives a link a view to open. --- # Add a surface Render the plugin's objects by contributing a surface. By the end of this page a link has a view, where a person can edit its title, address and note, and see whether they have read it. ## Background A surface is a named place in an app's UI, such as the main article area, a card or the status bar. Plugins fill surfaces by contributing a component and a filter that says which data the component can render. When Composer opens a link in the article area, it asks for every component that matches the article role and a link, and renders the best one. [Surfaces](/docs/plex/concepts/surfaces/) explains the matching. ## Steps 1. **Write the component.** The article surface passes the object as `subject`. `useObject` returns a snapshot of it and re-renders the component when it changes, whether the change came from this tab, another device or the assistant. Writes go through `Obj.update`, like any ECHO write. ```ts import type * as AppSurface from '@dxos/app-toolkit/AppSurface'; import { Obj } from '@dxos/echo'; import { useObject } from '@dxos/echo-react'; import * as Input from '@dxos/react-ui/Input'; import * as Layout from '@dxos/react-ui/Layout'; import * as Panel from '@dxos/react-ui/Panel'; import { type StashedLink } from '../types/StashedLink.ts'; export type StashedLinkArticleProps = AppSurface.ObjectArticleProps; export const StashedLinkArticle = ({ role, subject, }: StashedLinkArticleProps) => { // A snapshot that re-renders the component when the object changes, here or // on another peer. const [link] = useObject(subject); return ( Obj.update(subject, (subject) => { subject.title = event.target.value; }) } /> Obj.update(subject, (subject) => { subject.url = event.target.value; }) } /> Obj.update(subject, (subject) => { subject.note = event.target.value; }) } />

{link.read ? 'Read' : 'Not read yet'}

); }; ``` `Panel.Root` with the surface's `role` gives the view the standard article layout. 2. **Contribute the surface.** `AppSurface.object(AppSurface.Article, StashedLink)` matches the article role when the subject is a link. `props` maps the surface's data onto the component's props, and is type-checked against the filter. ```ts import * as Effect from 'effect/Effect'; import * as Capabilities from '@dxos/app-framework/Capabilities'; import * as Capability from '@dxos/app-framework/Capability'; import * as Surface from '@dxos/app-framework/Surface'; import * as AppSurface from '@dxos/app-toolkit/AppSurface'; import { StashedLinkArticle } from '../containers/StashedLinkArticle.tsx'; import { StashedLink } from '../types/StashedLink.ts'; export default Capability.makeModule(() => Effect.succeed( Capability.contribute( Capabilities.ReactSurface, Surface.create({ id: 'article', filter: AppSurface.object(AppSurface.Article, StashedLink), component: StashedLinkArticle, props: ({ role, data: { subject, attendableId } }) => ({ role, subject, attendableId, }), }), ), ), ); ``` 3. **Add the module to the manifest.** The `roles` list lets the plugin manager load the module only when it first needs an article surface. ```ts AppCapability.surface( () => import('./src/capabilities/react-surface.ts', { with: { type: 'module-url' }, }), { roles: [AppSurface.Article], }, ), ``` 4. **See the view.** Load or reload your plugin below, click **New** in the header to create a sample link, and select **Surface**. The inspector renders each of the module's surfaces whose filter accepts the sample: here, the article. Edit the title in the article's input, and the name in the header changes as you type. In Composer the same view fills the main area when a person opens a link. ## What you can do now - Read an object reactively in a component and write it with `Obj.update`. - Contribute an article surface for a type. - Load a surface module only when its role is needed. [Run it in Composer](/docs/plex/learn/run-in-composer/) loads the plugin into Composer from your machine, and gives the assistant Mark as read. --- # Run it in Composer Load the plugin from its dev server into Composer Preview, and use it there. By the end of this page Stash runs in Composer: a link opens in the main area, **Mark as read** runs from its toolbar, and the assistant marks links as read from a chat. ## Background Until now the inspector has loaded the plugin. Composer loads it the same way, from a manifest the plugin's dev server serves, and gives it the parts the inspector leaves out: a navigation tree, a command palette and the assistant. Only [Composer Preview](https://preview.composer.space) loads plugins from a dev server; composer.space loads only the plugins it ships with. ## Steps 1. **Sign in to Composer Preview.** Open [preview.composer.space](https://preview.composer.space) and sign in or create an identity. Keep the tab open. 2. **Keep the dev server running.** It is the one the inspector has loaded from since [Create a plugin](/docs/plex/learn/create-plugin/). If you stopped it, start it again in the plugin's directory: ```bash npm run dev ``` It serves the manifest at `http://localhost:3967/manifest.json`, which points at your unbundled source. If the port is taken, Vite fails rather than moving to another one, since Composer loads from 3967. 3. **Enable the dev plugin.** In Composer, click **Plugin Settings** in the rail and open **Plugins**. Under **Dev Server**, leave the manifest URL as it is and click **Enable**. Allow the browser's prompt to reach devices on your local network: the plugin's source comes from your machine. Stash appears in the list of plugins, turned on. It stays on until you click **Disable**, and every reload of Composer loads it again from the dev server, so an edit is one reload away. 4. **Stash a link.** Click **+** on a space and choose **Stashed link**. The space gets a **Stashed links** section with the link in it, and the link opens in the main area, in the article from [Add a surface](/docs/plex/learn/add-surface/). Give it a title, an address and a note. 5. **Mark it as read.** Click **Mark as read** on the link's toolbar, or run it from the command palette. The article changes to **Read**; the action is the one [Extend the App Graph](/docs/plex/learn/extend-app-graph/) added. 6. **Offer the operation to the assistant.** A skill gives the assistant a set of operations as tools, with instructions on when to use them. ```ts import * as Skill from '@dxos/compute/Skill'; import * as Template from '@dxos/compute/Template'; import { MarkRead } from '../types/StashedLinkOperation.ts'; export const key = 'com.example.skill.stash'; export const make = (): Skill.Skill => Skill.make({ key, name: 'Stash', tools: Skill.toolDefinitions({ operations: [MarkRead] }), instructions: Template.make({ source: 'When the user says they have read a page they stashed, find its ' + 'link and mark it as read.', }), }); ``` ```ts import * as Effect from 'effect/Effect'; import * as Capability from '@dxos/app-framework/Capability'; import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities'; import * as StashSkill from '../skills/StashSkill.ts'; export default Capability.makeModule(() => Effect.succeed( Capability.contribute(AppCapabilities.SkillDefinition, StashSkill), ), ); ``` Add the module to the manifest, then reload Composer to load it. ```ts AppCapability.skillDefinition( () => import('./src/capabilities/skill-definition.ts', { with: { type: 'module-url' }, }), ), ``` 7. **Run it from the assistant.** Create a second link and give it a title. Open a chat, add the **Stash** skill, and write "I've read" followed by that title. The assistant finds the link, calls the Mark as read tool with a reference to it, and the article changes to **Read**. ## What you can do now - Load a plugin into Composer Preview from its dev server. - Run a plugin's action from its toolbar and the command palette. - Give the assistant the operation through a skill, and have it run the operation from a chat. [Publishing a plugin](/docs/plex/publishing-plugins/) puts it in your private registry, so Composer lists it for you on every device. --- # Publishing a Plugin Publish a plugin to your private registry, where Composer lists it only to you This guide publishes a plugin to your **private registry**, so it appears in Composer's plugin registry for you on every device. It assumes you already have a working plugin. If you don't, start with the [learning path](/docs/plex/learn/create-plugin/) and use [`plugin-excalidraw`](https://github.com/dxos/plugin-excalidraw) as a reference implementation. :::note[Public registry] There is also an experimental public registry, where plugins are published over AT Protocol and listed to everyone. If you'd like to try it, reach out on [Discord](https://dxos.org/discord). ::: ## How the private registry works DXOS hosts your plugin's bundle and records it against your DXOS identity. Composer lists it in its plugin registry only when you are signed in as that identity, and loads the bundle on demand. Nothing is written to AT Protocol, and no verification is needed. The first release of a plugin key claims it, so no other identity can publish that key privately afterwards. ## Prerequisites - A built Composer plugin with a `dx.config.ts` (see [Describe your plugin](#3-describe-your-plugin-in-dxconfigts)). - A **DXOS identity**, the same one you use in Composer. - The DXOS CLI. ## 1. Install the CLI ```bash npm install -g @dxos/cli dx --version ``` ## 2. Log in ```bash dx account login ``` `login` prompts for a method, mirroring Composer's sign-in: `atmosphere` (your Atmosphere account, i.e. Bluesky), `email`, `device-invitation`, or `recovery-code`. Log out with `dx account logout`. > `dx account login` signs in to an **existing** identity; it doesn't create one. Create your identity in Composer, or run `dx account signup ` if you have an access code. A process with no identity of its own, such as a CLI in a sandbox or a CI job, sets an API token in `DX_API_TOKEN` instead. The token is bound to the account that minted it, so the plugin is listed to that account. In Composer, a sandbox gets one from its **Grant account access** action, which mints a token that expires with the sandbox. ## 3. Describe your plugin in `dx.config.ts` `dx.config.ts` at the root of your plugin package is the **single source of truth** for your plugin's registry metadata. The build reads it to emit the manifest, and `dx registry publish` reads that manifest to publish it. ```ts import { Config2 } from '@dxos/app-framework/config'; export default Config2.make({ plugin: { key: 'org.dxos.plugin.excalidraw', // required — a reverse-domain NSID; the plugin's globally-unique key name: 'Excalidraw', // required description: 'Professional diagramming powered by Excalidraw.', icon: { key: 'ph--compass-tool--regular', hue: 'indigo' }, source: 'https://github.com/your-org/your-plugin', tags: ['labs'], screenshots: [ { light: 'https://example.com/screenshot-light.png', dark: 'https://example.com/screenshot-dark.png', }, ], }, publish: { buildCommand: 'vite build', // how to build the bundle outputDirectory: 'dist', // where the build emits manifest.json }, }); ``` > Import `Config2` from `@dxos/app-framework/config`, not from `@dxos/app-framework`. The `/config` subpath is a lightweight entry point with no heavy transitive dependencies — the main entry pulls in the full UI framework which is not needed at build/publish time. Field reference for `plugin`: | Field | Required | Notes | | ------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `key` | yes | Reverse-domain NSID (e.g. `org.dxos.plugin.excalidraw`) whose last segment is camelCase — no hyphens. The plugin's globally-unique key. | | `name` | yes | Human-readable name shown in the registry. | | `description` | no | Short description shown on the plugin's detail view. | | `author` | no | Author or organization name. | | `icon` | no | `{ key, hue? }` — a [Phosphor](https://phosphoricons.com) icon name and optional display hue, e.g. `indigo`. | | `source` | no | Source repository URL. | | `homePage` | no | Homepage URL. | | `tags` | no | List of tags for categorization/discovery. | | `screenshots` | no | Preview images for the plugin's detail view. Each entry is a `{ light?, dark? }` record of theme-specific URLs. | | `spec` | no | Path of the plugin's `.mdl` spec inside the package, e.g. `PLUGIN.mdl`. See [Ship a spec](#ship-a-spec). | Field reference for `publish`: | Field | Required | Notes | | ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `buildCommand` | no | Build command run by `dx registry publish` (skipped with `--no-build`); unset or `vite build` runs the project's Vite in the `dx` process. | | `outputDirectory` | no | Directory the build emits into (must contain `manifest.json`). Defaults to `dist`. | The release **version is taken from your `package.json` `version` field**, not from `dx.config.ts`. Bump it before publishing a new release. Wire `composerPlugin` into your `vite.config.ts` so the build emits the manifest: ```ts import { composerPlugin } from '@dxos/app-framework/vite-plugin'; export default defineConfig({ plugins: [ ...composerPlugin({ entry: 'src/MyPlugin.tsx' }), // ...react(), etc. ], }); ``` > Pin every `@dxos/*` dependency to the **same version** the Composer host runs, and bump them in lockstep. A plugin built against mismatched framework versions can fail to load. ## 4. Publish From your plugin directory: ```bash dx registry publish --private ``` This will: 1. Build the bundle (skip with `--no-build` to publish a pre-built `dist`): with no `buildCommand`, or `vite build`, `dx` loads the Vite your project installs and runs it in its own process, so neither node nor a separate Vite process is needed; any other `buildCommand` runs in a shell. 2. Read the emitted `manifest.json`. 3. Upload the bundle to DXOS and record it against your identity. Useful flags: | Flag | Purpose | | ------------------ | ---------------------------------------------------------------------------------- | | `--dir ` | Project directory containing `dx.config.ts` (defaults to the current directory). | | `--no-build` | Skip the build and publish the existing `dist`. | | `--dry-run` | Build and check the bundle, then stop: nothing is uploaded or recorded. | | `--edge-url ` | Override the edge used for upload (mainly for local testing against a dev worker). | ## 5. Confirm it's published The command prints the bundle's URL. Open Composer's plugin registry while signed in as the same identity, and your plugin is listed there. Enable it like any other plugin. ## Updating a plugin Bump the `version` in `package.json`, then run `dx registry publish --private` again. Each version is published once, so bump the version to ship changes. ## Local development You don't need to publish to test your plugin against Composer. Composer loads a plugin from the URL of its **`manifest.json`**, so anything that serves a manifest and the entry module it names can be loaded. There are two ways to do it. ### From your plugin's dev server This is the loop to use while you work on a plugin. [Run it in Composer](/docs/plex/learn/run-in-composer/) walks through it: run `vite` in the plugin's directory, then enable it under **Dev Server** in Composer's Plugins settings. - **It is never installed.** A dev plugin is not saved in your profile or in the offline cache. If the dev server is down at boot, Composer logs a warning, shows it under Dev Server, and tries again on the next reload. - **It can stand in for an installed plugin.** When your `dx.config.ts` key matches a plugin you already have, from the registry or built in, the dev plugin takes its place for the session. Disable restores the original. - **React fast refresh does not reach it.** It is served from another origin, so reload the app rather than waiting for a hot update. ### From a built bundle Build the plugin (see [the example](#example-a-plugin-with-its-own-navtree-group)) and serve its `dist` directory, which contains `manifest.json` and `index.mjs`, with CORS if it is on another origin. In Composer, open **Plugins**, click **Load from URL** (the cloud icon in the Plugins header) and paste the manifest URL. Unlike a dev plugin, a plugin loaded this way is installed: it stays in your profile and its files are cached for offline use. The URL must point at the manifest, not at a source file: the loader fetches the manifest first and imports the entry it names. ### With an assistant To have the assistant build one for you, create a project from the **Composer Plugin** template in the Composer desktop app, with the Sandbox plugin on. Its parent task and four subtasks walk a chat through building a plugin in a sandbox on your computer, from fetching this guide to the load prompt; assign them to the agent to start it. In a browser, the template comes from the Dev Shell plugin and needs a Composer served locally by `vite preview`. There the chat writes the files and checks them against the plugin's dev server. The assistant cannot keep a dev server running, so start it yourself once the chat asks, from the Composer app directory, with `node_modules/.bin/vite` and the folder the chat names. When the chat says the plugin is ready, turn on **Dev Server** as above. The **Composer Plugin (Sandbox)** template, contributed by the Sandbox plugin, runs the same example against any bundled Composer, deployed ones included: the agent builds in an EDGE sandbox with `@dxos/*` installed from [pkg.pr.new](https://pkg.pr.new) at the commit the app was built from, serves `dist/` from the container, and offers the manifest URL of the port it exposed. > Both ways need a **bundled build** of Composer (`vite build` + `vite preview`, or a deployed app). A bundled > Composer publishes an import map that resolves your plugin's bare `@dxos/*`, `react` and `effect` imports to > the host's own copies; Composer's own Vite dev server has no import map, so those imports fail there. ### Example: a plugin with its own navtree group A small plugin in TypeScript that adds a group to every space's navtree, with one page under it, opened as an article. It builds with the official tooling into a `manifest.json` and an `index.mjs` that Composer loads by URL. Four files: ```ts // dx.config.ts import { Config2 } from '@dxos/app-framework/config'; export default Config2.make({ plugin: { key: 'org.example.plugin.hello', // must match the key the plugin declares; last segment camelCase name: 'Hello', icon: { key: 'ph--hand-waving--regular', hue: 'amber' }, tags: ['labs'], // lists it under Labs in the Plugins registry }, }); ``` ```ts // 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({ entry: 'src/plugin.tsx' }), react()], build: { outDir: 'dist' }, // any directory you serve }); ``` ```json // tsconfig.json { "compilerOptions": { "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", "jsx": "react-jsx", "strict": true, "noEmit": true, "skipLibCheck": true, "allowImportingTsExtensions": true, "types": [] }, "include": ["src", "dx.config.ts"] } ``` ```tsx // src/plugin.tsx import * as Effect from 'effect/Effect'; import React from 'react'; import * as ActivationEvents from '@dxos/app-framework/ActivationEvents'; import * as Capabilities from '@dxos/app-framework/Capabilities'; import * as Capability from '@dxos/app-framework/Capability'; import * as Plugin from '@dxos/app-framework/Plugin'; import * as Surface from '@dxos/app-framework/Surface'; import * as AppGraphBuilder from '@dxos/app-graph/AppGraphBuilder'; import * as AppGraphNode from '@dxos/app-graph/AppGraphNode'; import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities'; import * as AppNode from '@dxos/app-toolkit/AppNode'; import * as AppNodeMatcher from '@dxos/app-toolkit/AppNodeMatcher'; import * as AppSurface from '@dxos/app-toolkit/AppSurface'; import config from '../dx.config.ts'; const meta = Plugin.getMetaFromConfig(config); const GROUP = 'helloGroup'; const GROUP_TYPE = `${meta.profile.key}.group`; const PAGE = 'helloPage'; const HelloArticle = () => ( Hello from a plugin! ); export default Plugin.define(meta).pipe( Plugin.addModule({ id: 'appGraph', activatesOn: ActivationEvents.Startup, provides: [AppCapabilities.AppGraphBuilder], activate: () => Effect.gen(function* () { // A group: an uppercase heading in each space's navtree, between CONTENT and SYSTEM. const group = yield* AppGraphBuilder.createExtension({ id: 'helloGroup', match: AppNodeMatcher.whenSpace, connector: (space) => Effect.succeed([ AppNode.makeGroup({ id: GROUP, type: GROUP_TYPE, label: 'Hello', space, position: 400, }), ]), }); // A page in that group. The URL binding is what lets the deck open it. const page = yield* AppGraphBuilder.createExtension({ id: 'helloPage', match: AppNodeMatcher.whenNavTreeGroup(GROUP_TYPE), url: { key: PAGE, kind: 'singleton', path: [GROUP] }, connector: (space) => Effect.succeed([ AppGraphNode.make({ id: PAGE, type: `${meta.profile.key}.page`, data: PAGE, properties: { label: 'Hello', icon: 'ph--article--regular', selectable: true, draggable: false, droppable: false, space, }, }), ]), }); return [ Capability.contribute(AppCapabilities.AppGraphBuilder, [ ...group, ...page, ]), ]; }), }), Plugin.addModule({ id: 'surface', activatesOn: ActivationEvents.Startup, provides: [Capabilities.ReactSurface], activate: () => Effect.succeed([ Capability.contribute( Capabilities.ReactSurface, // Renders the page's article: the node's `data` is the subject. Surface.create({ id: 'helloArticle', filter: AppSurface.literal(AppSurface.Article, PAGE), component: HelloArticle, }), ), ]), }), Plugin.make, ); ``` A `package.json` beside them names what the build needs. Pin every `@dxos/*` package to the build of the Composer you load the plugin into: a release's npm version, or for a build of an unreleased commit of `main`, its pkg.pr.new build (`https://pkg.pr.new/@dxos/@`). `react`, `react-dom` and `effect` are the host's own copies at runtime, so match its versions too: ```json { "name": "hello", "version": "0.1.0", "private": true, "type": "module", "dependencies": { "@dxos/app-framework": "", "@dxos/app-graph": "", "@dxos/app-toolkit": "", "effect": "", "react": "", "react-dom": "" }, "devDependencies": { "@types/react": "", "@vitejs/plugin-react": "^6.0.0", "typescript": "^7.0.0", "vite": "^8.0.0" } } ``` Install, typecheck, then build, from the plugin's directory: ```bash npm install tsc -p tsconfig.json # vite does not typecheck vite build # writes dist/manifest.json and dist/index.mjs ``` Bun alone does too, with no node on the machine: `bunx @pnpm/exe@10 install` (with `node-version=24.11.1` in an `.npmrc`, or pnpm skips the bundler's native binary), then `bun run --bun tsc -p tsconfig.json` and `bun run --bun vite build`. That is how the desktop app's Composer Plugin project template builds, in a sandbox that holds nothing else. Serve `dist/` (with CORS, if it is on another origin) and load `/manifest.json`. After it loads, each space's navtree shows a HELLO group with a Hello page under it; selecting the page opens it. The version in the manifest comes from a `package.json` next to `dx.config.ts`, or `0.0.0` without one. Things to know: - A plugin can also be defined entirely in `dx.config.ts` with `Plugin.config({ plugin, modules })`, the way plugins inside the DXOS repository are, naming each module body with `import('./src/capabilities/x.ts', { with: { type: 'module-url' } })`. When `dx.config.ts` defines the plugin, `composerPlugin` builds from it instead of `src/plugin.tsx` and turns it into a static manifest whose module bodies are chunks of the bundle; the `Plugin.define(meta).pipe(...)` layout shown here keeps working. - Import only packages the host shares through its import map — the `@dxos/*` libraries and `react`, `react-dom` and `effect`; the build leaves those as bare imports. `@dxos/plugin-*` packages are not shared, so the build bundles whatever you use from them. - Ids are camelCase: the key's last segment (`org.example.plugin.helloWorld`, not `…hello-world`), module ids, graph extension ids, node ids and surface ids. A hyphenated key makes the module throw `Invalid DXN` when it is imported; a hyphenated extension or surface id is dropped without an error. - `AppGraphBuilder.createExtension` returns an `Effect`: `yield*` it and contribute the extensions it yields. - A group is a heading, not a page: it has no URL binding and shows only once something is under it. Its `position` orders it among the built-in groups (content 200, system 900). - A `singleton` page's node id must equal its URL `key`, and `path` names the group it sits under. - Every module lists the capabilities its `activate` returns in `provides`. - A plugin that fails to activate is disabled; fix it and re-enable it from the Plugins list (or reload). - The browser caches a module that failed to import, so reload Composer before loading a fixed copy from the same URL. ### Example: data, an operation and another plugin's menu Stash, the plugin the [learning path](/docs/plex/learn/the-plex-plugin/) builds, stores an ECHO type in the space, adds itself to the Space plugin's create menu, shows each link in an article, and marks links as read with an operation that people and the assistant both run. It is defined entirely in `dx.config.ts`, with one file per module body: ```ts // dx.config.ts import * as Plugin from '@dxos/app-framework/Plugin'; import * as AppCapability from '@dxos/app-toolkit/AppCapability'; import * as AppSurface from '@dxos/app-toolkit/AppSurface'; import * as SpaceCapability from '@dxos/plugin-space/SpaceCapability'; export default Plugin.config({ plugin: { key: 'com.example.plugin.stash', name: 'Stash', description: 'A reading list: save links with a note, and mark them as read.', icon: { key: 'ph--tray-arrow-down--regular', hue: 'indigo' }, tags: ['labs'], }, modules: [ AppCapability.schema( () => import('./src/capabilities/schema.ts', { with: { type: 'module-url' }, }), ), SpaceCapability.createObject( () => import('./src/capabilities/create-object.ts', { with: { type: 'module-url' }, }), ), AppCapability.surface( () => import('./src/capabilities/react-surface.ts', { with: { type: 'module-url' }, }), { roles: [AppSurface.Article], }, ), AppCapability.operationHandler( () => import('./src/capabilities/operation-handler.ts', { with: { type: 'module-url' }, }), ), AppCapability.skillDefinition( () => import('./src/capabilities/skill-definition.ts', { with: { type: 'module-url' }, }), ), ], }); ``` The type, with the annotations Composer reads: the field that labels a link, its icon, and `UserType`, which puts it in pickers and the navigation tree. The schema module registers it, so the space can store it: ```ts // src/types/StashedLink.ts import * as Schema from 'effect/Schema'; import { Annotation, DXN, Obj, Type } from '@dxos/echo'; export class StashedLink extends Type.makeObject( DXN.make('com.example.type.stashedLink', '0.1.0'), )( Schema.Struct({ title: Schema.optional(Schema.String.annotate({ title: 'Title' })), url: Schema.optional(Schema.String.annotate({ title: 'URL' })), note: Schema.optional(Schema.String.annotate({ title: 'Note' })), read: Schema.optional(Schema.Boolean.annotate({ title: 'Read' })), }).pipe( Annotation.LabelAnnotation.set(['title']), Annotation.IconAnnotation.set({ icon: 'ph--tray-arrow-down--regular', hue: 'indigo', }), Annotation.UserType.set(), ), ) {} export const make = ( props: Partial> = {}, ): StashedLink => Obj.make(StashedLink, { title: props.title, url: props.url, note: props.note ?? '', read: props.read ?? false, }); // src/capabilities/schema.ts import { StashedLink } from '../types/StashedLink.ts'; export default [StashedLink]; ``` The create menu belongs to the Space plugin. It lists every `CreateObjectEntry` contribution, so Stash contributes one; the space shows its form, built from the type's fields, and calls `createObject` with what the person typed: ```ts // src/capabilities/create-object.ts import * as Effect from 'effect/Effect'; import * as Capability from '@dxos/app-framework/Capability'; import * as Operation from '@dxos/compute/Operation'; import { Type } from '@dxos/echo'; import * as SpaceCapabilities from '@dxos/plugin-space/SpaceCapabilities'; import * as SpaceOperation from '@dxos/plugin-space/SpaceOperation'; import { StashedLink, make } from '../types/StashedLink.ts'; export default Capability.makeModule(() => Effect.succeed( Capability.contribute(SpaceCapabilities.CreateObjectEntry, { id: Type.getTypename(StashedLink), createObject: (props, options) => Operation.invoke( SpaceOperation.AddObject, { object: make(props), target: options.target }, { spaceId: options.db.spaceId }, ), }), ), ); ``` The operation is defined once, with a schema for its input and output, and its handler loads only when it first runs: ```ts // src/types/StashedLinkOperation.ts import * as Schema from 'effect/Schema'; import * as Operation from '@dxos/compute/Operation'; import { Database, DXN, Ref } from '@dxos/echo'; import { StashedLink } from './StashedLink.ts'; export const MarkRead = Operation.make({ meta: { key: DXN.make('com.example.operation.stashedLink.markRead'), name: 'Mark as read', description: 'Mark a link as read.', icon: 'ph--check-circle--regular', }, // The handler resolves the reference in the link's space. services: [Database.Service], input: Schema.Struct({ link: Ref.Ref(StashedLink).annotate({ description: 'The link that was read.', }), }), output: Schema.Void, }); // src/operations/mark-read.ts import * as Effect from 'effect/Effect'; import * as Operation from '@dxos/compute/Operation'; import { Database, Obj } from '@dxos/echo'; import { StashedLink } from '../types/StashedLink.ts'; import { MarkRead } from '../types/StashedLinkOperation.ts'; const handler: Operation.WithHandler = MarkRead.pipe( Operation.withHandler(({ link: ref }) => Effect.gen(function* () { // The assistant passes a reference by id, so look it up in the space. const link = yield* Database.resolve(ref, StashedLink); Obj.update(link, (link) => { link.read = true; }); }), ), ); export default handler; // src/capabilities/operation-handler.ts import * as Effect from 'effect/Effect'; import * as Capabilities from '@dxos/app-framework/Capabilities'; import * as Capability from '@dxos/app-framework/Capability'; import * as Operation from '@dxos/compute/Operation'; import * as OperationHandlerSet from '@dxos/compute/OperationHandlerSet'; import { MarkRead } from '../types/StashedLinkOperation.ts'; export default Capability.makeModule(() => Effect.succeed( Capability.contribute( Capabilities.OperationHandler, OperationHandlerSet.lazy([ MarkRead.pipe( Operation.lazyHandler(() => import('../operations/mark-read.ts')), ), ]), ), ), ); ``` A skill gives the operation to the assistant as a tool: ```ts // src/capabilities/skill-definition.ts import * as Effect from 'effect/Effect'; import * as Capability from '@dxos/app-framework/Capability'; import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities'; import * as Skill from '@dxos/compute/Skill'; import * as Template from '@dxos/compute/Template'; import { MarkRead } from '../types/StashedLinkOperation.ts'; export default Capability.makeModule(() => Effect.succeed( Capability.contribute(AppCapabilities.SkillDefinition, { key: 'com.example.skill.stash', make: () => Skill.make({ key: 'com.example.skill.stash', name: 'Stash', tools: Skill.toolDefinitions({ operations: [MarkRead] }), instructions: Template.make({ source: 'When the user says they have read a page they stashed, find its ' + 'link and mark it as read.', }), }), }), ), ); ``` The article surface matches any link the person opens, and edits its title and note: ```tsx // src/capabilities/react-surface.ts import * as Effect from 'effect/Effect'; import * as Capabilities from '@dxos/app-framework/Capabilities'; import * as Capability from '@dxos/app-framework/Capability'; import * as Surface from '@dxos/app-framework/Surface'; import * as AppSurface from '@dxos/app-toolkit/AppSurface'; import { StashedLinkArticle } from '../containers/StashedLinkArticle.tsx'; import { StashedLink } from '../types/StashedLink.ts'; export default Capability.makeModule(() => Effect.succeed( Capability.contribute( Capabilities.ReactSurface, Surface.create({ id: 'article', filter: AppSurface.object(AppSurface.Article, StashedLink), component: StashedLinkArticle, props: ({ role, data: { subject, attendableId } }) => ({ role, subject, attendableId, }), }), ), ), ); // src/containers/StashedLinkArticle.tsx import React from 'react'; import type * as AppSurface from '@dxos/app-toolkit/AppSurface'; import { Obj } from '@dxos/echo'; import { useObject } from '@dxos/echo-react'; import * as Input from '@dxos/react-ui/Input'; import * as Layout from '@dxos/react-ui/Layout'; import * as Panel from '@dxos/react-ui/Panel'; import { type StashedLink } from '../types/StashedLink.ts'; export type StashedLinkArticleProps = AppSurface.ObjectArticleProps; export const StashedLinkArticle = ({ role, subject, }: StashedLinkArticleProps) => { // A snapshot that re-renders the component when the object changes, here or // on another peer. const [link] = useObject(subject); return ( Obj.update(subject, (subject) => { subject.title = event.target.value; }) } /> Obj.update(subject, (subject) => { subject.url = event.target.value; }) } /> Obj.update(subject, (subject) => { subject.note = event.target.value; }) } />

{link.read ? 'Read' : 'Not read yet'}

); }; ``` Things to know: - Read an object you render with `useObject`: it re-renders on a field change. Reading `subject.note` directly in render does not subscribe, so an update is saved but never shown. - Write inside `Obj.update`; a write outside it throws. - An operation that people and the assistant both run is one definition: a person runs it from the app, and the skill gives it to the assistant as a tool. - A type without `UserType` is internal: Composer does not offer it in the create menu or show it in the navigation tree. The [learning path](/docs/plex/learn/the-plex-plugin/) builds this plugin one module at a time, with each step running in the inspector. ### Ship a spec A plugin can carry its `.mdl` spec (a `PLUGIN.mdl` beside `dx.config.ts`). The registry's detail view then shows **Open Specification**, which renders the spec with its definitions sidebar. It takes three changes: 1. Name the file in `dx.config.ts`: `spec: 'PLUGIN.mdl'`. 2. Contribute its text from the plugin, so the bundle carries it. The module loads the file only when the spec is first opened, and `pluginId` must be the plugin's key: ```tsx // src/plugin.tsx import * as AppCapability from '@dxos/app-toolkit/AppCapability'; export default Plugin.define(meta).pipe( // ...the plugin's other modules. Plugin.addModule( AppCapability.pluginAsset({ pluginId: meta.profile.key, path: 'PLUGIN.mdl', content: () => import('../PLUGIN.mdl?raw'), mimeType: 'application/x-mdl', }), ), Plugin.make, ); ``` When `dx.config.ts` defines the plugin with `Plugin.config`, add the module to its `modules` and give it a body of its own: ```ts // dx.config.ts, in `modules` Capability.lazyModule( 'pluginAsset', { provides: [AppCapabilities.PluginAsset], activatesOn: ActivationEvents.PluginAssetsRequested, environments: ['browser', 'tauri'], }, () => import('./src/capabilities/plugin-asset.ts', { with: { type: 'module-url' } }), ), ``` ```ts // src/capabilities/plugin-asset.ts import * as Effect from 'effect/Effect'; import * as Capability from '@dxos/app-framework/Capability'; import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities'; import content from '../../PLUGIN.mdl?raw'; export default Capability.makeModule(() => Effect.succeed( Capability.contribute(AppCapabilities.PluginAsset, { pluginId: 'org.example.plugin.hello', path: 'PLUGIN.mdl', content, mimeType: 'application/x-mdl', }), ), ); ``` 3. Declare Vite's `?raw` imports for `tsc`, since the example `tsconfig.json` loads no ambient types: ```ts // src/raw.d.ts declare module '*?raw' { const content: string; export default content; } ``` ## Command reference | Command | Purpose | | ------------------------------- | --------------------------------------------------------------------------------- | | `dx account signup ` | Redeem an access code to create an account, via your Atmosphere account or email. | | `dx account login` | Log in to your DXOS identity. | | `dx account logout` | Log out of the current profile. | | `dx registry publish --private` | Build and host the bundle, and list it only to your identity. | Run any command with `--help` for its full set of options. --- # Plugins and modules A plugin is metadata plus modules, and an app is the plugins it loads. A plugin is metadata plus a set of modules, and a PLEX app is the plugins it loads. ## Model 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](/docs/plex/concepts/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. ```ts import * as Plugin from '@dxos/app-framework/Plugin'; export const HelloPlugin = Plugin.define(meta).pipe( Plugin.addModule(GreetingModule), 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. ```ts import * as Plugin from '@dxos/app-framework/Plugin'; import * as AppCapability from '@dxos/app-toolkit/AppCapability'; import { StashNamespace } from './src/namespace.ts'; const config: Plugin.PluginConfig = Plugin.config({ plugin: { key: StashNamespace.key, name: 'Stash', author: 'Example', description: 'A reading list: save links with a note, and mark them as read.', icon: { key: 'ph--tray-arrow-down--regular', hue: 'indigo' }, tags: ['labs'], }, modules: [ // #region translations AppCapability.translations( () => import('./src/translations.ts', { with: { type: 'module-url' } }), ), ``` The Stash plugin from the [learning path](/docs/plex/learn/the-plex-plugin/) has seven modules. Each waits on an event and contributes one capability. Five of them: ## Behavior - 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. ```ts import * as Effect from 'effect/Effect'; import * as ActivationEvents from '@dxos/app-framework/ActivationEvents'; import * as 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 LazyGreetingModule = Capability.lazyModule( 'LazyGreeting', { provides: [Greeting], activatesOn: ActivationEvents.Idle }, async () => ({ default: Capability.makeModule(() => Effect.succeed(Capability.contribute(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](/docs/plex/concepts/activation/) covers the rules. - Plugins never import each other's modules. A plugin that extends another contributes to a capability the other reads. ## Limits - 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. --- # Capabilities Typed contributions that modules provide and request, which is how plugins extend each other. A capability is a typed slot that modules contribute values to and other modules read, so plugins extend each other without importing each other. ## Model A capability is a **tag**: an NSID plus the TypeScript type of its values. `Capability.make` declares a multi capability, which any number of modules contribute to. `Capability.makeSingleton` declares one with exactly one provider. ```ts import * as Capability from '@dxos/app-framework/Capability'; export type Exporter = { readonly format: string; readonly export: (text: string) => Uint8Array; }; // A multi capability: any number of modules may contribute one. export const Exporter = Capability.make()( 'com.example.plugin.export.capability.exporter', ); // A singleton capability: exactly one module provides it. export const Clock = Capability.makeSingleton<() => Date>()( 'com.example.plugin.export.capability.clock', ); ``` A module **contributes** by returning `Capability.contribute(tag, value)` from its body, and lists the tag in `provides`. A module **requests** with `yield*` on the tag, or with `Capability.getAll` for every contribution to a multi capability, and lists what it cannot run without in `requires`. ```ts import * as Effect from 'effect/Effect'; import * as Capability from '@dxos/app-framework/Capability'; export const ExportMenuModule = Capability.inlineModule( 'ExportMenu', { requires: [Clock], provides: [] }, () => Effect.gen(function* () { const now = yield* Clock; const exporters = yield* Capability.getAll(Exporter); console.log( now(), exporters.map((exporter) => exporter.format), ); }), ); ``` The capability manager holds every contribution. It is the `Capability.Service` in a module's Effect context, and the plugin manager owns it. ## Behavior - The compiler checks a module's body against its `provides`: a body that returns a contribution the module does not list does not compile. - The plugin manager activates a module only after every capability in its `requires` has a contributor, so a module can read them without waiting. - Four ways to read a capability suit different needs: | Call | Returns | When | | --- | --- | --- | | `Capability.get` | The first contribution, or fails with `CapabilityNotFoundError` | The capability must be there. | | `Capability.getAll` | Every contribution, possibly none | A multi capability. | | `Capability.waitFor` | The first contribution, once one arrives | It is contributed later than this module runs. | | `Capability.atom` | An atom of every contribution | Reactive code that should update as plugins come and go. | ```ts import * as Effect from 'effect/Effect'; import * as ActivationEvents from '@dxos/app-framework/ActivationEvents'; import * as Capability from '@dxos/app-framework/Capability'; import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities'; // `waitFor` suspends until some module contributes the capability, so this // module can activate on Startup even though App Graph arrives later. export const GraphReadyModule = Capability.inlineModule( 'GraphReady', { provides: [], activatesOn: ActivationEvents.Startup }, () => Effect.gen(function* () { const graph = yield* Capability.waitFor(AppCapabilities.AppGraph); // An atom of every contribution, for reactive code. const schemas = yield* Capability.atom(AppCapabilities.Schema); console.log(typeof graph, schemas); }), ); ``` - One plugin extends another by contributing to a capability the other reads. Stash adds itself to the Space plugin's create menu this way: the Space plugin declares `SpaceCapabilities.CreateObjectEntry` and lists every contribution in its menu, and Stash contributes one entry. ```ts import * as Effect from 'effect/Effect'; import * as Capability from '@dxos/app-framework/Capability'; import * as Operation from '@dxos/compute/Operation'; import { Type } from '@dxos/echo'; import * as SpaceCapabilities from '@dxos/plugin-space/SpaceCapabilities'; import * as SpaceOperation from '@dxos/plugin-space/SpaceOperation'; import { StashedLink, make } from '../types/StashedLink.ts'; export default Capability.makeModule(() => Effect.succeed( Capability.contribute(SpaceCapabilities.CreateObjectEntry, { id: Type.getTypename(StashedLink), createObject: (props, options) => Operation.invoke( SpaceOperation.AddObject, { object: make(props), target: options.target }, { spaceId: options.db.spaceId }, ), }), ), ); ``` - Contributions change after startup, when a module activates late or a person turns a plugin on or off. `Capability.contributions` and `Capability.atom` stay current; a value read once with `getAll` does not. - When a module deactivates, its contributions are removed and its finalizers run. ## Limits - A singleton capability with two providers is an error. Use a multi capability for anything more than one plugin might contribute. - The capability manager does not check a value against anything but its TypeScript type. Two plugins that build against different versions of a capability's type are not caught at runtime. - Capabilities are in-process. They do not cross from the app to a worker or another device. --- # Activation When a module runs, the events that activate it, and the order the plugin manager follows. Activation is the plugin manager running a module's body: once, when the module's event has fired and every capability it requires has been contributed. ## Model An **activation event** is a named moment in the app's life, such as startup or the first time an article is shown. A module names the event it waits for in `activatesOn`, and can combine several: `ActivationEvent.oneOf` activates on the first of them, and `ActivationEvent.allOf` waits for all of them. ```ts import * as Effect from 'effect/Effect'; import * as ActivationEvent from '@dxos/app-framework/ActivationEvent'; import * as Capability from '@dxos/app-framework/Capability'; export const IndexModule = Capability.inlineModule( 'Index', { provides: [Index], activatesOn: ActivationEvent.oneOf(IndexRequested, SearchStart), }, () => Effect.succeed(Capability.contribute(Index, new Map())), ); ``` A module's `requires` is the second gate. The **plugin manager** keeps a graph of which module provides each capability, and activates providers before the modules that require them, whichever event activated the provider. Registration order plays no part. ```ts import * as Effect from 'effect/Effect'; import * as Capability from '@dxos/app-framework/Capability'; // No `activatesOn`: this module waits for `Index`, so it activates whenever the // module above does. export const QueryModule = Capability.inlineModule( 'Query', { requires: [Index], provides: [] }, () => Effect.gen(function* () { const index = yield* Index; console.log(index.size); }), ); ``` ## Behavior - The app fires `ActivationEvents.Startup` while it boots and `ActivationEvents.Idle` once it has painted and the browser is idle. A module with no `activatesOn` belongs to the Idle wave, so only what the first screen needs runs at startup. - The framework fires further events when something first needs a module. `ActivationEvents.SurfacesRequested(role)` fires the first time a surface of that role renders; `CommandsRequested` fires when the command palette or CLI asks for commands. The module makers in `AppCapability` choose the event, so the surface module of a plugin loads with the first article, not at startup. - A module can fire an event itself with `Plugin.activate`, which is how one plugin loads another's modules on demand. ```ts import * as Plugin from '@dxos/app-framework/Plugin'; export const requestIndex = Plugin.activate(IndexRequested); ``` - An event stays fired. A module added after its event, for example by a plugin a person turns on later, activates at once. - A lazy module's code is imported only when it is about to activate. A module whose event never fires is never loaded, which is what keeps a large app's boot small. - Plugins come and go while the app runs. Enabling one registers its modules and activates every module whose event has fired; disabling one deactivates its modules in reverse order, runs their finalizers and removes their contributions. [PluginManager](/docs/plex/guides/plugin-manager/) shows the calls. - A module that fails, or does not finish within 30 seconds, is recorded on the manager's `failed` list and its plugin is disabled, so one broken plugin does not stop the app from starting. ## Limits - Two modules that require each other's capabilities form a cycle, and the plugin manager fails them with `DependencyCycleError` at runtime. - A module that requires a capability no enabled plugin provides waits forever, without an error. The [inspector](/docs/plex/guides/debugging/) shows such a module as waiting. - Activation order is a graph, not a sequence. Do not rely on two modules with no requirement between them activating in a particular order. --- # Operations Named, typed actions that the UI, the command palette and agents all run the same way. An operation is an action with a name, a typed input and a typed output, defined once and run from anywhere in an app, by people and by agents. ## Model An operation **definition** has a key (a DXN), metadata for people and agents, and schemas for its input and output. A **handler** is an Effect that takes the input and returns the output. Definitions and handlers are separate values, so a plugin can export a definition for other plugins to call without exposing how it runs. ```ts import * as Schema from 'effect/Schema'; import * as Operation from '@dxos/compute/Operation'; import { Database, DXN, Ref } from '@dxos/echo'; import { StashedLink } from './StashedLink.ts'; export const MarkRead = Operation.make({ meta: { key: DXN.make('com.example.operation.stashedLink.markRead'), name: 'Mark as read', description: 'Mark a link as read.', icon: 'ph--check-circle--regular', }, // The handler resolves the reference in the link's space. services: [Database.Service], input: Schema.Struct({ link: Ref.Ref(StashedLink).annotate({ description: 'The link that was read.', }), }), output: Schema.Void, }); ``` Plugins contribute handlers in an **operation handler set** through the `OperationHandler` capability. The **operation invoker** looks up the handler by the definition's key and runs it. Any module, handler or graph action can invoke an operation with `Operation.invoke`; a component uses `useOperation`. ```ts import * as Effect from 'effect/Effect'; import * as Operation from '@dxos/compute/Operation'; import { Ref } from '@dxos/echo'; import { type StashedLink } from './src/types/StashedLink.ts'; import { MarkRead } from './src/types/StashedLinkOperation.ts'; // Any module, handler or action can run an operation by its definition. export const markRead = (link: StashedLink) => Operation.invoke(MarkRead, { link: Ref.make(link) }).pipe( Effect.catchTag('NoHandlerError', () => Effect.succeed(undefined)), ); ``` A **skill** hands operations to the assistant. It is a name, instructions, and a list of operations that become the assistant's tools; a plugin contributes it through `AppCapabilities.SkillDefinition`. ```ts import * as Skill from '@dxos/compute/Skill'; import * as Template from '@dxos/compute/Template'; import { MarkRead } from '../types/StashedLinkOperation.ts'; export const key = 'com.example.skill.stash'; export const make = (): Skill.Skill => Skill.make({ key, name: 'Stash', tools: Skill.toolDefinitions({ operations: [MarkRead] }), instructions: Template.make({ source: 'When the user says they have read a page they stashed, find its ' + 'link and mark it as read.', }), }); ``` Stash's Operations module in the inspector runs Mark as read from a form built from its input schema: ## Behavior - One definition serves three callers. A button invokes it through a hook. The command palette lists it. A skill hands it to the assistant as a tool, and the assistant reads the schema descriptions to fill in the input. None of the three needs code of its own. - The invoker validates the input against the schema before the handler runs, and the output after. - Errors are typed. A handler fails with tagged errors that a caller catches by tag, and invoking an operation that no plugin handles fails with `NoHandlerError`. - An operation can be undone. A plugin contributes an `UndoMapping` that names the operation's inverse and derives the inverse's input from the forward input and output; the app records each undoable invocation and offers to undo it. ```ts import * as Effect from 'effect/Effect'; import * as Schema from 'effect/Schema'; import * as ActivationEvents from '@dxos/app-framework/ActivationEvents'; import * as Capabilities from '@dxos/app-framework/Capabilities'; import * as Capability from '@dxos/app-framework/Capability'; import * as Operation from '@dxos/compute/Operation'; import { DXN } from '@dxos/echo'; const Rename = Operation.make({ meta: { key: DXN.make('com.example.operation.rename'), name: 'Rename', }, input: Schema.Struct({ id: Schema.String, name: Schema.String }), output: Schema.Struct({ previous: Schema.String }), }); // The inverse is the same operation with the previous name. export const RenameUndoModule = Capability.inlineModule( 'RenameUndo', { provides: [Capabilities.UndoMapping], activatesOn: ActivationEvents.Startup, }, () => Effect.succeed( Capability.contribute(Capabilities.UndoMapping, [ { operation: Rename, inverse: Rename, deriveContext: (input, output) => ({ id: input.id, name: output.previous, }), message: 'Renamed', }, ]), ), ); ``` - Handlers load lazily. A lazy handler set names each handler's module, and the framework imports it the first time the operation is invoked. - The App Toolkit defines operations every plugin can call. `LayoutOperation.Open` opens items by path, `LayoutOperation.Close` closes them, and `SpaceOperation.AddObject` adds an object to a space. ## Limits - A handler runs with the services its definition declares, plus the operation service for invoking other operations. It does not get the React tree. - An operation's input must be serializable for the assistant to call it. Pass objects by reference where the caller is an agent. - Undo replays the inverse operation; it does not restore a snapshot. An operation whose effect cannot be inverted from its input and output is not undoable. --- # Surfaces How the framework decides which component renders an object in a given place. A surface is a named place in an app's UI that plugins fill with components. The host asks for "the article for this object" and does not know which plugin will answer. ## Model A **role** names a place: `Article` is the main content area, `Section` is an object inside a stack, `CardContent` is the body of a card, and `StatusIndicator` is the status bar. Each role declares the shape of the data it passes, such as `{ subject, attendableId }` for an article. The roles a plugin fills most often, such as `Article` for the main content area, come from `AppSurface` in `@dxos/app-toolkit`. [App capabilities](/docs/plex/guides/app-capabilities/#surface-roles) lists them. [Surface](/docs/plex/guides/surface/) lists the rest and shows how to narrow a role's `subject` to one type. A **surface contribution** is a component plus a **filter**. The filter names one or more roles and a guard on the data, for example "the article role, when the subject is a Note". Plugins contribute surfaces through the `ReactSurface` capability. ```ts import * as Surface from '@dxos/app-framework/Surface'; import * as AppSurface from '@dxos/app-toolkit/AppSurface'; export const NoteArticleSurface = Surface.create({ id: 'article', // Matches the article role when the data's subject is a Note. filter: AppSurface.object(AppSurface.Article, Note), component: NoteArticle, // Maps the surface's data onto the component's props; checked against the // filter's data type. props: ({ role, data: { subject, attendableId } }) => ({ role, subject, attendableId, }), }); ``` Stash's Surface module in the inspector renders its article with the sample link: ## Behavior - The host renders ``. The framework collects every contribution whose filter names that role and whose guard accepts the data. - Contributions are ordered by their `position`, then by plugin order. The host renders the first one, or the first `limit` of them when it wants several, as a toolbar does. - A filter can match several roles at once with `AppSurface.oneOf`, or require two conditions with `AppSurface.allOf`. - A component can render another plugin's surface by rendering `Surface.Surface` itself. This is how a stack shows each object with whatever plugin owns its type, and how a markdown document embeds a table. ```ts import * as Surface from '@dxos/app-framework/Surface'; import * as AppSurface from '@dxos/app-toolkit/AppSurface'; // Renders whatever surface another plugin contributed for this object in the // section role. export const Embedded = ({ subject }: { subject: Note }) => ( ); ``` - A surface module loads lazily. The manifest lists the roles the module serves, and the plugin manager imports the module the first time one of those roles is requested. ## Limits - A surface component gets only the data its role passes. It must not assume which container it is in, how wide it is, or that it is the only surface on screen; the same article can show in a plank, a stack or a dialog. - The `props` mapper of a surface runs during the host's render, so it cannot call hooks. A component that needs hooks is its own component. - A surface does not own the object it renders. Other surfaces, devices and agents edit the same object, so the component reads it reactively. --- # Effect Use Effect services and layers from a plugin's modules and operations. A module's body and an operation's handler are Effects. A plugin can read the capability manager as an Effect service, contribute layers of its own, and name the services an operation needs. ## Read capabilities as a service Inside a module, the capability manager is `Capability.Service`. `Capability.get`, `getAll` and `waitFor` read it for you; yield the service itself when you need the manager. ```ts import * as Effect from 'effect/Effect'; import * as Capability from '@dxos/app-framework/Capability'; import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities'; // Inside a module, the capability manager is the `Capability.Service` in // context; `Capability.get` and friends read it. export const CountSchemasModule = Capability.makeModule(() => Effect.gen(function* () { const manager = yield* Capability.Service; const schemas = manager.getAll(AppCapabilities.Schema).flat(); console.log(schemas.length); return []; }), ); ``` ## Contribute a layer A service an operation uses comes from a layer. Contribute a `LayerSpec` to `Capabilities.LayerSpec`: it says what the layer provides, what it requires and how widely one instance is shared. With `affinity: 'application'` the app builds the layer once and every operation shares it. ```ts import * as Context from 'effect/Context'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import * as Capabilities from '@dxos/app-framework/Capabilities'; import * as Capability from '@dxos/app-framework/Capability'; import * as LayerSpec from '@dxos/compute/LayerSpec'; export class LinkChecker extends Context.Service< LinkChecker, { readonly check: (url: string) => Effect.Effect } >()('com.example.plugin.stash.LinkChecker') {} const LinkCheckerLive = Layer.succeed(LinkChecker, { check: (url) => Effect.succeed(url.startsWith('https://')), }); // A layer spec says what the layer provides and how widely it is shared; the // runtime builds it for any operation that declares the service. export const LinkCheckerModule = Capability.makeModule(() => Effect.succeed( Capability.contribute( Capabilities.LayerSpec, LayerSpec.make( { affinity: 'application', requires: [], provides: [LinkChecker] }, () => LinkCheckerLive, ), ), ), ); ``` ## Declare an operation's services An operation lists the services it needs in `services`. When it is invoked, the runtime builds them from the layer specs plugins contributed, and the handler yields them like any Effect service. ```ts import * as Effect from 'effect/Effect'; import * as Schema from 'effect/Schema'; import * as Operation from '@dxos/compute/Operation'; import { DXN } from '@dxos/echo'; export const CheckLink = Operation.make({ meta: { key: DXN.make('com.example.operation.checkLink'), name: 'Check link', }, input: Schema.Struct({ url: Schema.String }), output: Schema.Struct({ ok: Schema.Boolean }), services: [LinkChecker], }); export const checkLink = CheckLink.pipe( Operation.withHandler(({ url }) => Effect.gen(function* () { const checker = yield* LinkChecker; return { ok: yield* checker.check(url) }; }), ), ); ``` An operation whose services have no layer fails when invoked, not when the plugin loads. [Testing a plugin](/docs/plex/guides/testing/) catches it. ## Limits - Never run an Effect with `Effect.runPromise` inside a module. Return it, so the plugin manager can interrupt it when the module deactivates. - A layer spec's service is in-process. A service that must run on EDGE needs a layer there too. --- # Testing Test a plugin's modules without an app, with the test harness. ```ts import * as Harness from '@dxos/plugin-testing/Harness'; ``` A plugin's modules run in a plugin manager, so a test starts one. `Harness.createComposerTestApp` starts a headless app with the core plugins Composer gives every plugin, plus the plugins under test. It runs on an in-memory client, so it needs no browser, account or server. ## Start the app ```ts import * as Harness from '@dxos/plugin-testing/Harness'; import { make as makePlugin } from './dx.config.ts'; // A headless app with the core plugins Composer gives a plugin, plus this one. await using harness = await Harness.createComposerTestApp({ plugins: [makePlugin()], }); ``` `await using` closes the app when the test ends, which deactivates every module and runs its finalizers. ## Fire activation events The harness fires the startup events. A module gated on a later event stays inactive until the test fires that event, so a test can check what a module does before and after it activates. ```ts import * as ActivationEvents from '@dxos/app-framework/ActivationEvents'; import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities'; // Startup has fired; modules gated on later events wait until the test fires them. await harness.fire(ActivationEvents.Idle); const builders = harness.getAll(AppCapabilities.AppGraphBuilder); console.log(builders.length > 0); // true ``` ## Read capabilities `getAll` returns every contribution to a capability, as `Capability.getAll` does inside a module. `waitForCapability` waits for a capability whose module has not activated yet. ```ts import * as Capabilities from '@dxos/app-framework/Capabilities'; import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities'; const schemas = harness.getAll(AppCapabilities.Schema).flat(); const handlers = await harness.waitForCapability(Capabilities.OperationHandler); console.log(schemas.length, typeof handlers); ``` ## Invoke an operation `invoke` runs an operation through the app's invoker, with the handlers the plugins contributed, and returns its output. A typed error fails the promise. ```ts import { Ref } from '@dxos/echo'; import { make } from './src/types/StashedLink.ts'; import { MarkRead } from './src/types/StashedLinkOperation.ts'; const link = make({ title: 'DXOS', url: 'https://dxos.org' }); await harness.invoke(MarkRead, { link: Ref.make(link) }); console.log(link.read); ``` ## Limits - The harness renders no UI. Test a surface's component on its own, with a story or a component test. - Plugins that need a real network or device, such as a connector, need their own test layers. --- # Debugging Find out why a module did not activate or a contribution did not show. Most plugin bugs are one of three things: the plugin did not load, a module did not activate, or a contribution reached the app but did not show where you expected. Start with the inspector, which shows all three. ## Load the plugin into the inspector Start the plugin's dev server in its folder with `npm run dev`, then click **Load dev plugin** below. The inspector loads the plugin from `http://localhost:3967/manifest.json`, the same way Composer does. The sidebar lists each module with its status: - **Active:** the module ran and its contributions are in the app. - **Waiting:** its activation event has not fired yet. Check that the module lists the event you meant. - **Failed:** the module threw. Select it to read the error. ## Load errors When the plugin does not load at all, the inspector names the reason: | Error | Meaning | | --- | --- | | Couldn't reach the manifest | The dev server is not running, is on another port, or the browser blocked the request to your local network. | | Not a plugin manifest | Something answered on port 3967, but `vite.config.ts` does not add `composerPlugin`. | | The plugin's code failed to load | A module threw while importing. The message is the module's own error. | | Metadata does not match | The key in `dx.config.ts` differs from the one the plugin's code declares. | ## Debug in Composer Preview Load the plugin into [Composer Preview](https://preview.composer.space) as on [Run it in Composer](/docs/plex/learn/run-in-composer/), then open the browser's devtools. The plugin's source is served unbundled, so its files appear under `localhost:3967` in the Sources panel, and breakpoints and `console.log` work as in any Vite app. A reload of Composer loads your latest edit. ## Limits - The inspector shows one plugin. A bug that depends on another plugin, such as a surface another plugin's surface outranks, shows only in Composer. - Only Composer Preview loads dev plugins; composer.space does not. --- # App capabilities The capabilities a workspace app such as Composer adds for plugins to contribute, from the App Toolkit. ```ts import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities'; import * as AppCapability from '@dxos/app-toolkit/AppCapability'; import * as AppSurface from '@dxos/app-toolkit/AppSurface'; import * as UiHooks from '@dxos/react-ui/Hooks'; ``` The framework defines how plugins extend each other, but not what an app is made of. `@dxos/app-toolkit` adds that for a workspace app such as Composer: a layout, App Graph, settings, translations, the standard surface roles. Its capabilities are in `AppCapabilities`, and Composer and any other app built with the toolkit read them. `AppCapability` has a module maker for most of them, which sets the activation event and environments so a manifest only names the file. ## A tour, by group Most plugins touch a handful of these. The rest exist for the plugins that build the app's own features, and a plugin that is not one of those can ignore them. - **Layout.** `Layout` is the current layout as an atom: what is open, the sidebar, the mode. Plugins read it; the layout plugin provides it. Changing it goes through `LayoutOperation`. - **App Graph.** `AppGraphBuilder` adds nodes and actions, `AppGraph` is the graph itself for reading, and `AppGraphRetention` keeps nodes loaded while they are off screen. [App Graph](/docs/plex/guides/app-graph/) covers them. - **Settings and text.** `Settings` adds a section to the settings page, and `Translations` gives the plugin's strings their text. [Settings and state](/docs/plex/guides/settings-and-state/) covers settings, and [Translations](#translations) below covers text. - **Schema.** `Schema` registers the ECHO types the app can store, query and name. [Using ECHO in a plugin](/docs/plex/guides/using-echo/) covers it. - **Agents.** `SkillDefinition` gives the assistant a skill, `Toolkit` adds tools outside a skill, and `McpServer` exposes a built-in MCP server. - **Files and content.** `FileUploader` stores an uploaded file, `Exporter` adds an export format, `TextContent` extracts an object's text for search and agents, and `PluginAsset` publishes a file other tools can read, such as the plugin's spec. | Capability | Maker | What it adds | | --- | --- | --- | | `AppGraph` | none | The graph itself, for reading. | | `AppGraphBuilder` | `appGraphBuilder` | Nodes and actions in App Graph. | | `AppGraphRetention` | none | Nodes App Graph keeps loaded. | | `Exporter` | `exporter` | An export format for objects. | | `FileUploader` | none | Storage for files people add. | | `Layout` | none | The current layout, as an atom. | | `McpServer` | `mcpServer` | A built-in MCP server. | | `PluginAsset` | `pluginAsset` | A file other tools can read, such as the plugin's spec. | | `Schema` | `schema` | ECHO types the app can store, query and name. | | `Settings` | `settings` | A section in the settings page. | | `SkillDefinition` | `skillDefinition` | A skill the assistant can use. | | `SpaceTemplate` | `spaceTemplates` | A template for new spaces. | | `TextContent` | `textContent` | The text of an object, for search and agents. | | `Toolkit` | none | Tools for the assistant outside a skill. | | `Translations` | `translations` | Text for the plugin's strings and type labels. | `AppCapability.surface` and `AppCapability.operationHandler` make modules for the framework's `ReactSurface` and `OperationHandler` capabilities. ## Translations A plugin never writes its UI text inline. It contributes translation resources, keyed by language and namespace, and reads strings by key, so the app can show them in the person's language and other plugins can label its objects. ### Contribute translation resources A resource maps a language to namespaces, and each namespace to keys and their text. Use the plugin's key as the namespace for its own strings, and a type's typename for the strings that describe the type. `AppCapability.translations` contributes the resources; it activates at startup, since labels are needed for the first screen. ```ts import { Type } from '@dxos/echo'; import { StashNamespace } from './namespace.ts'; import { StashedLink } from './types/StashedLink.ts'; export const translations = [ { 'en-US': { [StashNamespace.key]: { 'plugin.name': 'Stash', 'mark-read.label': 'Mark as read', }, // #region type [Type.getTypename(StashedLink)]: { 'typename.label': 'Stashed link', 'typename.label_zero': 'Stashed links', 'typename.label_one': 'Stashed link', 'typename.label_other': 'Stashed links', 'object-name.placeholder': 'New link', 'add-object.label': 'Add link', }, ``` ```ts AppCapability.translations( () => import('./src/translations.ts', { with: { type: 'module-url' } }), ), ``` ### The keys the app reads for a type The app labels objects of a type from that type's namespace, so a type with these keys reads well in every menu: | Key | Where it shows | | --- | --- | | `typename.label` | The type's name, such as in the create menu. | | `typename.label_zero`, `_one`, `_other` | The plural forms, such as a section heading. | | `object-name.placeholder` | The name of a new object before the person names it. | | `add-object.label` | The create menu's entry for the type. | ### Read a string from a component `useTranslation` takes a namespace and returns `t`. A key with a `count` picks its plural form. ```ts import * as UiHooks from '@dxos/react-ui/Hooks'; import { StashNamespace } from './src/namespace.ts'; export const PluginTitle = () => { // Strings resolve in the plugin's namespace; the app picks the language. const { t } = UiHooks.useTranslation(StashNamespace.key); return

{t('plugin.name')}

; }; ``` ```ts import * as UiHooks from '@dxos/react-ui/Hooks'; export const LinkCount = ({ count }: { count: number }) => { const { t } = UiHooks.useTranslation('com.example.type.stashedLink'); // `count` picks `typename.label_zero`, `_one` or `_other`. return {`${count} ${t('typename.label', { count })}`}; }; ``` App Graph nodes and actions take a label as a key and namespace, `['mark-read.label', { ns }]`, and the app translates it where it renders. ## Surface roles The roles a plugin fills most often are in `AppSurface` from `@dxos/app-toolkit`: | Role | Where it renders | What it is given | | --- | --- | --- | | `Article` | The main content area, one object at a time | `subject`, `attendableId` | | `Section` | An object inside a stack of objects | `subject`, `attendableId` | | `CardContent` | The body of a card, in search results and boards | `subject` | | `ObjectProperties` | The object's properties panel | `subject` | | `Dialog`, `Popover` | A dialog or popover a plugin opens by name | `component`, `props` | | `Navigation` | The navigation sidebar | `current`, the open item's id | | `StatusIndicator` | The status bar | no data | A plugin can render into any role another plugin defines, and declare its own. See [Surfaces](/docs/plex/concepts/surfaces/). ## Contribute settings, metadata and schema registrations A settings contribution names a prefix, a schema and a writable atom. The app renders a form from the schema and writes the person's changes to the atom. ```ts import * as Effect from 'effect/Effect'; import * as Atom from 'effect/reactivity/Atom'; import * as Schema from 'effect/Schema'; import * as Struct from 'effect/Struct'; import * as Capability from '@dxos/app-framework/Capability'; import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities'; export const Settings = Schema.Struct({ showSeconds: Schema.optional( Schema.Boolean.annotate({ title: 'Show seconds' }), ), }).mapFields(Struct.map(Schema.mutableKey)); export interface Settings extends Schema.Schema.Type {} export const SettingsModule = Capability.makeModule(() => Effect.sync(() => { // A writable atom. Real plugins persist it with `KvsStore.make` from // `@dxos/effect`. const atom = Atom.make({ showSeconds: false }); return Capability.contribute(AppCapabilities.Settings, { prefix: ClockNamespace.key, schema: Settings, atom, }); }), ); ``` In a manifest, each maker takes the loader of the module that contributes the value: ```ts import * as AppCapability from '@dxos/app-toolkit/AppCapability'; // Each maker declares the capability and its default activation event, so a // manifest names only the loader. export const modules = [ AppCapability.settings(async () => ({ default: SettingsModule })), AppCapability.translations([ { 'en-US': { [ClockNamespace.key]: { 'plugin.name': 'Clock' } } }, ]), ]; ``` The tutorial plugin registers its type with `AppCapability.schema`, whose module's default export is the list of types: ```ts import { StashedLink } from '../types/StashedLink.ts'; export default [StashedLink]; ``` ## App capability or a new one Use an app capability when you are adding to something the app already shows: a setting, a node, a skill. Declare a new capability when your plugin is the host and other plugins add to it, the way the Space plugin declares `SpaceCapabilities.CreateObjectEntry` for the types in its create menu. [Capability](/docs/plex/guides/capability/) shows how. ## Related | Page | What it covers | | --- | --- | | [Capability](/docs/plex/guides/capability/) | Declaring and reading capabilities. | | [Plugin](/docs/plex/guides/plugin/) | Modules and makers. | | [React](/docs/plex/guides/react/) | The other hooks a plugin component uses. | --- # Settings and state Keep a plugin's settings, which sync, apart from its device state, which does not. A plugin keeps two kinds of value outside ECHO: - **Settings** are choices the person makes, such as a default view. They follow the person to every device. - **State** belongs to one device, such as the last item opened or a panel's width. It does not sync. Keep them apart. A device-only value stored as a setting moves to the person's other devices, where it is wrong. ## Settings Describe the settings with a schema, and contribute it with `AppCapabilities.Settings`. The app renders a form for it on the settings page, using each field's `title`, and syncs the values. `KvsStore.make` gives an atom that holds the values, with defaults for a first run. ```ts import * as Effect from 'effect/Effect'; import * as Schema from 'effect/Schema'; import * as Struct from 'effect/Struct'; import * as Capability from '@dxos/app-framework/Capability'; import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities'; import * as KvsStore from '@dxos/effect/KvsStore'; import { StashNamespace } from './src/namespace.ts'; export const Settings = Schema.Struct({ openInNewTab: Schema.optional( Schema.Boolean.annotate({ title: 'Open links in a new tab' }), ), }).mapFields(Struct.map(Schema.mutableKey)); // The app renders a form from the schema and syncs the values to the person's // other devices. export const SettingsModule = Capability.makeModule(() => Effect.sync(() => Capability.contribute(AppCapabilities.Settings, { prefix: StashNamespace.key, schema: Settings, atom: KvsStore.make({ key: StashNamespace.key, schema: Settings, defaultValue: () => ({ openInNewTab: true }), }), }), ), ); ``` ## State Device state is the plugin's own capability. Declare a singleton on the plugin's namespace and store it under the key `${key}.state`, so it stays on this device. ```ts import * as Effect from 'effect/Effect'; import * as Schema from 'effect/Schema'; import * as Struct from 'effect/Struct'; import * as Capability from '@dxos/app-framework/Capability'; import * as KvsStore from '@dxos/effect/KvsStore'; import { StashNamespace } from './src/namespace.ts'; export const State = Schema.Struct({ lastOpened: Schema.optional(Schema.String), }).mapFields(Struct.map(Schema.mutableKey)); // Device state is the plugin's own capability, stored under its own key. export const StateCapability = StashNamespace.makeSingleton>()('state'); const makeState = () => KvsStore.make({ key: `${StashNamespace.key}.state`, schema: State, defaultValue: () => ({}), }); export const StateModule = Capability.makeModule(() => Effect.sync(() => Capability.contribute(StateCapability, makeState())), ); ``` ## Read and write them Both are atoms. A component reads one with `useAtomValue` and writes it with `useAtomSet`, after getting it with `useCapability`. Other modules read them the same way they read any capability. ## Limits - Settings are not ECHO objects, so they do not appear in a space or in search. - Change a settings schema only by adding optional fields, so values stored by an earlier version still decode. --- # App Graph The graph of nodes and actions that an app's navigation and menus are built from, and how a plugin adds to it. ```ts import * as AppGraph from '@dxos/app-graph/AppGraph'; import * as AppGraphBuilder from '@dxos/app-graph/AppGraphBuilder'; import * as AppGraphNode from '@dxos/app-graph/AppGraphNode'; import * as AppNode from '@dxos/app-toolkit/AppNode'; ``` App Graph is a graph of everything a person can open or do in a PLEX app. In Composer, the navigation tree, toolbars, context menus and the command palette all read it, so a plugin that adds to App Graph shows up in all of them. App Graph comes from the App Toolkit, not the framework: it is how a workspace app such as Composer organizes what plugins add. A PLEX app can run without it. ## How App Graph is shaped A **node** has an id, a type, data and properties. Its data is usually the thing it stands for, such as a space or an ECHO object; its properties carry the label, icon and display hints. The graph starts from a root node. Nodes connect by **relations**. `child` makes the navigation tree: a space's children are its sections and objects. `action` connects a node to the things a person can do to it; an action node's data is a function that returns an Effect. Other relations, such as `companion`, attach panels to a node. A node's identity is its **path**: the ids from the root down to it, joined like a URL. `GraphPath` from `@dxos/app-toolkit` builds the paths of the common nodes, such as a space or an object in it, and navigation operations take paths. In Composer the address bar shows the open node's path. Plugins add nodes through **builder extensions**. An extension has a matcher and a connector: the matcher picks the nodes it applies to, and the connector returns the nodes to connect to them. For the usual section that lists an object type, `TypeSection.createTypeSectionExtension` builds the extensions for you, with an address for each object. Stash uses it: ```ts import * as AppNodeMatcher from '@dxos/app-toolkit/AppNodeMatcher'; import * as GraphPath from '@dxos/app-toolkit/GraphPath'; import * as TypeSection from '@dxos/app-toolkit/TypeSection'; import { StashedLink } from '../types/StashedLink.ts'; /** A "Stashed links" section in each space's Content group, once it has one. */ const section = TypeSection.createTypeSectionExtension(StashedLink, { match: AppNodeMatcher.whenNavTreeGroup(GraphPath.GroupTypes.content), groupSegment: GraphPath.GroupSegments.content, // The link's address in Composer's URL: `/w//stash/`. urlKey: 'stash', }); ``` Stash's App Graph module in the inspector shows the branches that lead to what it added: ## How it behaves - App Graph expands lazily. The framework runs a node's extensions only when something asks for that node's connections, such as the person opening a branch in the tree. A space with ten thousand objects costs nothing until it is opened. - A connector reads reactive state through `get`. When that state changes, such as a query gaining a result, the connector reruns and the node's connections update. - Nodes nothing is showing can be dropped again. A plugin that must keep a node loaded while it is off screen, such as the one a panel is pinned to, contributes it through `AppCapabilities.AppGraphRetention`. - Actions reach every menu from one place. An action node carries a disposition that says where it appears: `toolbar` for an object's toolbar, `list-item` for its context menu in the tree. The command palette lists every action of the open node, so an action needs no command of its own. ```ts import * as Effect from 'effect/Effect'; import * as AppGraphBuilder from '@dxos/app-graph/AppGraphBuilder'; import * as Operation from '@dxos/compute/Operation'; import { Obj, Ref } from '@dxos/echo'; import { StashNamespace } from '../namespace.ts'; import { StashedLink } from '../types/StashedLink.ts'; import { MarkRead } from '../types/StashedLinkOperation.ts'; /** An action on every link, on its toolbar and its menu in the tree. */ const stashedLinkActions = AppGraphBuilder.createTypeExtension({ id: 'stashedLinkActions', type: StashedLink, actions: (link) => Effect.succeed([ { id: 'markRead', data: () => Operation.invoke( MarkRead, { link: Ref.make(link) }, { spaceId: Obj.getDatabase(link)?.spaceId }, ), properties: { label: ['mark-read.label', { ns: StashNamespace.key }], icon: 'ph--check-circle--regular', disposition: ['toolbar', 'list-item'], }, }, ]), }); ``` ## Write a graph builder extension `AppGraphBuilder.createExtension` takes an id, a matcher and a connector. The matcher returns `Option.some(value)` for the nodes it applies to; `AppNodeMatcher` has ready-made ones such as `whenSpace`. The connector returns the nodes to connect. Read reactive state with `get` so the connector reruns when it changes. ```ts import * as Effect from 'effect/Effect'; import * as AppGraphBuilder from '@dxos/app-graph/AppGraphBuilder'; import * as AppNode from '@dxos/app-toolkit/AppNode'; import * as AppNodeMatcher from '@dxos/app-toolkit/AppNodeMatcher'; import { Filter } from '@dxos/echo'; // One node per note, as a child of each space node. const notes = AppGraphBuilder.createExtension({ id: 'notes', match: AppNodeMatcher.whenSpace, connector: (space, get) => { const objects = get(space.db.query(Filter.type(Note)).atom); return Effect.succeed( objects .map((object) => AppNode.makeObject({ get, db: space.db, object })) .filter((node): node is NonNullable => node !== null), ); }, }); ``` ## Attach actions to nodes `AppGraphBuilder.createTypeExtension` matches nodes whose data is an object of one type. Its `actions` callback returns action nodes. `AppGraphNode.makeAction` makes one; `disposition` decides where it shows. `AppNode.makeToolbarAction` is a shortcut for the object toolbar. ```ts import * as Effect from 'effect/Effect'; import * as AppGraphBuilder from '@dxos/app-graph/AppGraphBuilder'; import * as AppGraphNode from '@dxos/app-graph/AppGraphNode'; import { Obj } from '@dxos/echo'; // An action on every node whose data is a Note. const pin = AppGraphBuilder.createTypeExtension({ id: 'pin', type: Note, actions: (note) => Effect.succeed([ AppGraphNode.makeAction({ id: 'pin', data: () => Effect.sync(() => Obj.update(note, (note) => (note.pinned = !note.pinned)), ), properties: { label: 'Pin', icon: 'ph--push-pin--regular', disposition: 'list-item', }, }), ]), }); ``` Both builders return Effects. Run them in the module body and contribute the result through `AppCapabilities.AppGraphBuilder`. ```ts import * as Effect from 'effect/Effect'; import * as Capability from '@dxos/app-framework/Capability'; import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities'; export default Capability.makeModule( Effect.fnUntraced(function* () { const extensions = yield* Effect.all([notes, pin]); return Capability.contribute( AppCapabilities.AppGraphBuilder, extensions.flat(), ); }), ); ``` ## Work with AppNode and GraphPath `AppNode` builds the common kinds of node: `makeObject` for an ECHO object, `makeSection` for a heading in the tree, `makeCompanion` and `makeDeckCompanion` for panels. `GraphPath` builds and parses node paths, which navigation operations take. ```ts import * as GraphPath from '@dxos/app-toolkit/GraphPath'; export const pathOfNote = (note: Note, spaceId: string) => GraphPath.getSpacePath(spaceId, note.id); ``` ## Expand and read the graph from a component `useAppGraph` returns the graph. Expand a node before reading its connections, because the graph builds them lazily. `graph.connections(id, relation)` is the same data as an atom, for a component that should update as the graph changes. ```ts import { useEffect, useState } from 'react'; import * as AppGraph from '@dxos/app-graph/AppGraph'; import * as AppGraphNode from '@dxos/app-graph/AppGraphNode'; import * as Hooks from '@dxos/app-toolkit/Hooks'; import * as EffectEx from '@dxos/effect/EffectEx'; export const ChildCount = ({ id }: { id: string }) => { const { graph } = Hooks.useAppGraph(); const [count, setCount] = useState(0); useEffect(() => { // Children are built lazily: expand the node, then read its connections. void EffectEx.runPromise( AppGraph.expand(graph, id, AppGraphNode.child), ).then(() => setCount(AppGraph.getConnections(graph, id, AppGraphNode.child).length), ); }, [graph, id]); return {count}; }; ``` ## Limits - An extension's id and a node's id must be camelCase. A hyphenated id is dropped without an error. - A connector must be cheap and must not write. It runs again on every change to the state it reads. - Paths are stable only while the ids along them are. Moving an object to another space changes its path. ## Related | Page | What it covers | | --- | --- | | [Extend the App Graph](/docs/plex/learn/extend-app-graph/) | Sections, items and actions end to end. | | [Operation](/docs/plex/guides/operation/) | The operations that actions invoke. | --- # React The React hooks for plugins. ```ts import * as Hooks from '@dxos/app-framework/Hooks'; ``` A plugin's components read capabilities, invoke operations and read settings through hooks. They work in any component under the app's plugin manager, which every surface is. ## Read capabilities from a component `useCapability` returns a singleton capability, and `useCapabilities` every contribution to a multi capability. Both re-render when contributions change. `useOptionalCapability` returns `undefined` when no plugin provides it. ```ts import * as Capabilities from '@dxos/app-framework/Capabilities'; import * as Hooks from '@dxos/app-framework/Hooks'; export const SurfaceCount = () => { // Every contribution to a multi capability, re-rendering when plugins add or // remove one. const surfaces = Hooks.useCapabilities(Capabilities.ReactSurface); return {surfaces.length}; }; ``` ## Invoke operations from a component `useOperation` binds an operation to a callback; its argument maps the callback's arguments to the operation's input. `useOperationInvoker` returns the invoker, whose `invokePromise` returns the operation's output. ```ts import { useCallback } from 'react'; import * as Hooks from '@dxos/app-framework/Hooks'; import * as LayoutOperation from '@dxos/app-toolkit/LayoutOperation'; import * as Button from '@dxos/react-ui/Button'; export const OpenButton = ({ path }: { path: string }) => { const open = Hooks.useOperation(LayoutOperation.Open, () => ({ subject: [path], })); return open()}>Open; }; export const OpenTwice = ({ path }: { path: string }) => { const { invokePromise } = Hooks.useOperationInvoker(); const handleClick = useCallback(async () => { await invokePromise(LayoutOperation.Open, { subject: [path] }); }, [invokePromise, path]); return Open; }; ``` ## Read plugin settings and translations `useAtomCapability` reads an atom capability, such as a plugin's settings, and re-renders when it changes. `useAtomCapabilityState` also returns a setter. `useTranslation` from `@dxos/react-ui/Hooks` takes the plugin's namespace. ```ts import * as Hooks from '@dxos/app-framework/Hooks'; import * as Button from '@dxos/react-ui/Button'; import * as UiHooks from '@dxos/react-ui/Hooks'; export const CompactToggle = () => { const [settings, setSettings] = Hooks.useAtomCapabilityState(Settings); const { t } = UiHooks.useTranslation('com.example.plugin.notes'); return ( setSettings((current) => ({ ...current, compact: !current.compact })) } > {settings.compact ? t('expand.label') : t('compact.label')} ); }; ``` `@dxos/app-toolkit/Hooks` adds hooks for app state, such as `useActiveSpace`, `useAppGraph` and `useLayout`. ## The reactivity rules - Reading an ECHO object's field in render does not subscribe the component. Use `useObject` for a snapshot that does, and `useQuery` for a list. See the ECHO [React guide](/docs/echo/guides/react/). - Write objects inside `Obj.update`, from an event handler or an operation, never during render. - A component never assumes it is the only view of an object. The same object can be open in another plank, on another device, or in the assistant's hands. ```ts import { useCallback } from 'react'; import * as ToolkitHooks from '@dxos/app-toolkit/Hooks'; import { Obj } from '@dxos/echo'; import { useObject } from '@dxos/echo-react'; import * as Button from '@dxos/react-ui/Button'; export const NoteTitle = ({ note }: { note: Note }) => { // Reading `note.title` in render does not subscribe. `useObject` returns a // snapshot that does. const [snapshot] = useObject(note); const space = ToolkitHooks.useActiveSpace(); const rename = useCallback( () => Obj.update(note, (note) => (note.title = 'Renamed')), [note], ); return ( {snapshot.title} ); }; ``` ## Related | Page | What it covers | | --- | --- | | [Surface](/docs/plex/guides/surface/) | Where these components render. | | [Operation](/docs/plex/guides/operation/) | Defining the operations a component invokes. | --- # ECHO Register a plugin's ECHO types with the app, so it can create, name and list their objects. A plugin stores its data as ECHO objects in the person's spaces. The types themselves are ordinary ECHO types, which [Defining types](/docs/echo/learn/defining-types/) covers. This page covers what a plugin adds so the app knows about them. ## Register the types The app can store, query and show an object only if its type is registered. A plugin registers its types with the `Schema` capability: a module returns the list, and `AppCapability.schema` contributes it. ```ts import { StashedLink } from '../types/StashedLink.ts'; export default [StashedLink]; ``` ```ts AppCapability.schema( () => import('./src/capabilities/schema.ts', { with: { type: 'module-url' }, }), ), ``` A type registered by no plugin still syncs, since ECHO stores any object, but the app cannot name, list or create it. ## Annotate the type for the app Annotations on the schema tell the app how to present an object: - `LabelAnnotation` names the property that holds the object's label, shown in the navigation tree and in search. - `IconAnnotation` gives the type an icon and a hue. - `UserType` marks a type people create themselves, so it appears in the app's create menus. Leave it off types that only a plugin creates, such as a sync cursor. ```ts import * as Schema from 'effect/Schema'; import { Annotation, DXN, Type } from '@dxos/echo'; export class StashedLink extends Type.makeObject( DXN.make('com.example.type.stashedLink', '0.1.0'), )( Schema.Struct({ title: Schema.optional(Schema.String.annotate({ title: 'Title' })), url: Schema.optional(Schema.String.annotate({ title: 'URL' })), note: Schema.optional(Schema.String.annotate({ title: 'Note' })), read: Schema.optional(Schema.Boolean.annotate({ title: 'Read' })), }).pipe( Annotation.LabelAnnotation.set(['title']), Annotation.IconAnnotation.set({ icon: 'ph--tray-arrow-down--regular', hue: 'indigo', }), Annotation.UserType.set(), ), ) {} ``` The type's name in the menus comes from the plugin's translations, keyed by its typename. See [App capabilities](/docs/plex/guides/app-capabilities/#translations). ## Create objects Give each type a `make` function that fills in defaults, so every plugin that creates one gets a valid object. ```ts import { Obj } from '@dxos/echo'; export const make = ( props: Partial> = {}, ): StashedLink => Obj.make(StashedLink, { title: props.title, url: props.url, note: props.note ?? '', read: props.read ?? false, }); ``` To add the type to the app's create menu, contribute a `SpaceCapabilities.CreateObjectEntry`. The Space plugin lists every entry, and calls `createObject` with the properties the person entered and the place they chose. `SpaceOperation.AddObject` adds the object to the space, so the change can be undone and other plugins see it. ```ts import * as Effect from 'effect/Effect'; import * as Capability from '@dxos/app-framework/Capability'; import * as Operation from '@dxos/compute/Operation'; import { Type } from '@dxos/echo'; import * as SpaceCapabilities from '@dxos/plugin-space/SpaceCapabilities'; import * as SpaceOperation from '@dxos/plugin-space/SpaceOperation'; import { StashedLink, make } from '../types/StashedLink.ts'; export default Capability.makeModule(() => Effect.succeed( Capability.contribute(SpaceCapabilities.CreateObjectEntry, { id: Type.getTypename(StashedLink), createObject: (props, options) => Operation.invoke( SpaceOperation.AddObject, { object: make(props), target: options.target }, { spaceId: options.db.spaceId }, ), }), ), ); ``` ## Limits - A plugin can register only types it defines or depends on. It cannot change another plugin's type. - Turning a plugin off does not delete its objects. They stay in the space, and the app shows them again once the plugin is back on. --- # Plugin Define a plugin and its modules. ```ts import * as Plugin from '@dxos/app-framework/Plugin'; ``` `Plugin` defines a plugin: its metadata and the modules it is made of. A plugin does nothing by itself; its modules contribute everything it adds to the app. ## Define a plugin with its metadata A plugin's metadata names it. The key is a reverse-domain DXN whose last segment is camelCase. ```ts import * as Plugin from '@dxos/app-framework/Plugin'; import { DXN } from '@dxos/echo'; const meta = Plugin.makeMeta({ key: DXN.make('com.example.plugin.hello'), name: 'Hello', description: 'Contributes one greeting.', }); ``` Plugins in a package with a `dx.config.ts` describe the same metadata there, and `Plugin.config` derives the `meta`. [Create a plugin](/docs/plex/learn/create-plugin/) shows that form. ## Add modules and choose their activation events A module declares the capabilities it `provides` and `requires`, and optionally the event it `activatesOn`. The plugin manager activates a module when its event fires and every capability it requires has been contributed. A module with no `activatesOn` activates when the app is idle after startup. ```ts import * as Effect from 'effect/Effect'; import * as ActivationEvents from '@dxos/app-framework/ActivationEvents'; import * as Capability from '@dxos/app-framework/Capability'; const Greeting = Capability.make()( 'com.example.plugin.hello.capability.greeting', ); const GreetingModule = Capability.inlineModule( 'Greeting', { provides: [Greeting], activatesOn: ActivationEvents.Startup }, () => Effect.succeed(Capability.contribute(Greeting, 'Hello, world')), ); ``` `Capability.inlineModule` keeps the body in the same file. `Capability.lazyModule` loads it from another chunk when the module activates, so a plugin with many modules costs little at startup. ```ts import * as Effect from 'effect/Effect'; import * as ActivationEvents from '@dxos/app-framework/ActivationEvents'; import * as 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 LazyGreetingModule = Capability.lazyModule( 'LazyGreeting', { provides: [Greeting], activatesOn: ActivationEvents.Idle }, async () => ({ default: Capability.makeModule(() => Effect.succeed(Capability.contribute(Greeting, 'Hello, later')), ), }), ); ``` `Plugin.define` starts a plugin from its metadata, `Plugin.addModule` adds each module, and `Plugin.make` returns the factory the plugin manager calls. ```ts import * as Plugin from '@dxos/app-framework/Plugin'; export const HelloPlugin = Plugin.define(meta).pipe( Plugin.addModule(GreetingModule), Plugin.make, ); ``` ## Contribute capabilities from a module A module body returns the contributions it declared in `provides`. The compiler checks the match: a missing or undeclared contribution is a type error at `Plugin.addModule`. [Capability](/docs/plex/guides/capability/) covers declaring and requesting capabilities. ## Describe the plugin in `dx.config.ts` A plugin that is published describes itself in a `dx.config.ts` manifest. `Plugin.config` takes the metadata, the registry's fields such as tags, and the modules, each made with a maker from `AppCapability` that names the file to load. It returns `meta` and `make`, so the registry reads the plugin without importing its modules. ```ts import * as Plugin from '@dxos/app-framework/Plugin'; import * as AppCapability from '@dxos/app-toolkit/AppCapability'; import { StashNamespace } from './src/namespace.ts'; const config: Plugin.PluginConfig = Plugin.config({ plugin: { key: StashNamespace.key, name: 'Stash', author: 'Example', description: 'A reading list: save links with a note, and mark them as read.', icon: { key: 'ph--tray-arrow-down--regular', hue: 'indigo' }, tags: ['labs'], }, modules: [ // #region translations AppCapability.translations( () => import('./src/translations.ts', { with: { type: 'module-url' } }), ), ``` [Testing a plugin](/docs/plex/guides/testing/) boots a plugin in a headless app. ## Related | Page | What it covers | | --- | --- | | [Plugins and modules](/docs/plex/concepts/plugins/) | How plugins, modules, events and capabilities fit together. | | [ActivationEvent](/docs/plex/guides/activation-event/) | Declaring events and the built-in ones. | | [API reference](/docs/plex/resources/api-reference/) | Every export of `@dxos/app-framework`. | --- # Capability Declare, contribute and request capabilities, including the built-in ones. ```ts import * as Capabilities from '@dxos/app-framework/Capabilities'; import * as Capability from '@dxos/app-framework/Capability'; ``` A capability is a typed slot that modules contribute values to and other modules read. Capabilities are how plugins extend each other without importing each other. ## Declare a new capability type `Capability.make` declares a multi capability, which any number of modules contribute to. `Capability.makeSingleton` declares one with exactly one provider. Both take the value's type and an NSID. ```ts import * as Capability from '@dxos/app-framework/Capability'; export type Exporter = { readonly format: string; readonly export: (text: string) => Uint8Array; }; // A multi capability: any number of modules may contribute one. export const Exporter = Capability.make()( 'com.example.plugin.export.capability.exporter', ); // A singleton capability: exactly one module provides it. export const Clock = Capability.makeSingleton<() => Date>()( 'com.example.plugin.export.capability.clock', ); ``` `Plugin.namespace` derives the NSID from the plugin's key, so a plugin's capabilities share its prefix. ```ts import * as Plugin from '@dxos/app-framework/Plugin'; const ExportNamespace = Plugin.namespace('com.example.plugin.export'); export const Settings = ExportNamespace.makeSingleton<{ defaultFormat: string; }>()('settings'); ``` ## Contribute an implementation and request all implementations A module lists the capability in `provides` and returns `Capability.contribute(tag, value)`. ```ts import * as Effect from 'effect/Effect'; import * as ActivationEvents from '@dxos/app-framework/ActivationEvents'; import * as Capability from '@dxos/app-framework/Capability'; export const TextExporterModule = Capability.inlineModule( 'TextExporter', { provides: [Exporter], activatesOn: ActivationEvents.Startup }, () => Effect.succeed( Capability.contribute(Exporter, { format: 'text/plain', export: (text) => new TextEncoder().encode(text), }), ), ); ``` A module that lists a capability in `requires` activates only after it is contributed, and reads it with `yield*`. `Capability.getAll` reads every contribution to a multi capability. ```ts import * as Effect from 'effect/Effect'; import * as Capability from '@dxos/app-framework/Capability'; export const ExportMenuModule = Capability.inlineModule( 'ExportMenu', { requires: [Clock], provides: [] }, () => Effect.gen(function* () { const now = yield* Clock; const exporters = yield* Capability.getAll(Exporter); console.log( now(), exporters.map((exporter) => exporter.format), ); }), ); ``` ## The built-in capabilities `Capabilities` holds the capabilities the framework itself defines. The ones most plugins touch: | Capability | What a plugin does with it | | --- | --- | | `ReactSurface` | Contributes surface components. See [Surface](/docs/plex/guides/surface/). | | `OperationHandler` | Contributes operation handlers. See [Operation](/docs/plex/guides/operation/). | | `OperationInvoker` | Reads it to invoke operations outside a handler. | | `ReactContext`, `ReactRoot` | Wraps the app in a provider, or adds a root-level component. | | `AtomRegistry` | Reads and writes atoms outside React. | The App Toolkit's capabilities, such as settings and App Graph, are in [App capabilities](/docs/plex/guides/app-capabilities/). ```ts import * as Effect from 'effect/Effect'; import * as Capabilities from '@dxos/app-framework/Capabilities'; import * as Capability from '@dxos/app-framework/Capability'; // Built-in capabilities are tags like any other: a singleton yields its one // implementation. export const ReportModule = Capability.inlineModule( 'Report', { requires: [Capabilities.OperationInvoker], provides: [] }, () => Effect.gen(function* () { const invoker = yield* Capabilities.OperationInvoker; console.log(typeof invoker.invokePromise); }), ); ``` ## React to capabilities that arrive after startup Contributions change as people turn plugins on and off. `Capability.contributions` returns a live view with `get` and `subscribe`; `Capability.atom` returns the same as an atom for reactive code. ```ts import * as Effect from 'effect/Effect'; import * as Capability from '@dxos/app-framework/Capability'; // Contributions that arrive after startup, for example from a plugin the user // turns on later. // The view is live: a module subscribes, and the finalizer unsubscribes when // the module deactivates. export const ExportListModule = Capability.inlineModule( 'ExportList', { provides: [] }, () => Effect.gen(function* () { const exporters = yield* Capability.contributions(Exporter); const unsubscribe = exporters.subscribe((current) => console.log(current.map((exporter) => exporter.format)), ); yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); }), ); ``` In React, `useCapabilities` re-renders on each change. See [React](/docs/plex/guides/react/). ## Related | Page | What it covers | | --- | --- | | [Plugin](/docs/plex/guides/plugin/) | Modules and how they declare `provides` and `requires`. | | [App capabilities](/docs/plex/guides/app-capabilities/) | The app-level capabilities the App Toolkit defines. | --- # ActivationEvent Declare activation events and use the built-in ones. ```ts import * as ActivationEvent from '@dxos/app-framework/ActivationEvent'; import * as ActivationEvents from '@dxos/app-framework/ActivationEvents'; ``` An activation event tells the plugin manager when a module should load. Modules wait for their event, so a plugin's code loads when it is needed, not all at startup. ## Declare an activation event `ActivationEvent.make` declares an event from an NSID. `ActivationEvent.pluginStart` returns a plugin's own start event, which the plugin manager fires on demand and while it is idle. ```ts import * as ActivationEvent from '@dxos/app-framework/ActivationEvent'; // The last segment of the NSID must be camelCase; the compiler rejects // `index-ready`. export const IndexRequested = ActivationEvent.make( 'com.example.plugin.search.event.indexRequested', ); // Every plugin has a start event of its own, fired on demand and by the host's // idle trickle. export const SearchStart = ActivationEvent.pluginStart( 'com.example.plugin.search', ); ``` ## Order modules with events and requirements A module's `activatesOn` can name one event, or combine several with `ActivationEvent.oneOf` and `ActivationEvent.allOf`. ```ts import * as Effect from 'effect/Effect'; import * as ActivationEvent from '@dxos/app-framework/ActivationEvent'; import * as Capability from '@dxos/app-framework/Capability'; export const IndexModule = Capability.inlineModule( 'Index', { provides: [Index], activatesOn: ActivationEvent.oneOf(IndexRequested, SearchStart), }, () => Effect.succeed(Capability.contribute(Index, new Map())), ); ``` Requirements order modules within an event. A module that requires a capability activates after the module that provides it, whichever event activated the provider. Registration order does not matter. ```ts import * as Effect from 'effect/Effect'; import * as Capability from '@dxos/app-framework/Capability'; // No `activatesOn`: this module waits for `Index`, so it activates whenever the // module above does. export const QueryModule = Capability.inlineModule( 'Query', { requires: [Index], provides: [] }, () => Effect.gen(function* () { const index = yield* Index; console.log(index.size); }), ); ``` A module fires an event with `Plugin.activate`. This is how a plugin loads another plugin's modules on demand, for example when a person opens the create dialog. ```ts import * as Plugin from '@dxos/app-framework/Plugin'; export const requestIndex = Plugin.activate(IndexRequested); ``` ## The built-in events | Event | When it fires | | --- | --- | | `ActivationEvents.Startup` | Once, while the app boots. Only modules needed for the first paint belong here. | | `ActivationEvents.Idle` | After the first paint, when the app is idle. The default for a module with no `activatesOn`. | | `ActivationEvents.SurfacesRequested(role)` | The first time a surface of that role is rendered. | | `ActivationEvents.CommandsRequested` | When the command palette or CLI asks for commands. | | `ActivationEvents.PluginAssetsRequested` | When a tool asks for plugin assets, such as a spec. | ```ts import * as ActivationEvents from '@dxos/app-framework/ActivationEvents'; export const events = [ ActivationEvents.Startup, ActivationEvents.Idle, ActivationEvents.SurfacesRequested('org.dxos.role.article'), ]; ``` Module makers such as `AppCapability.surface` set the right event for their capability, so most manifests never name one. ## Related | Page | What it covers | | --- | --- | | [Plugin](/docs/plex/guides/plugin/) | Defining modules. | | [Capability](/docs/plex/guides/capability/) | Requirements between modules. | --- # PluginManager Create the plugin manager, and add, enable and remove plugins. ```ts import * as PluginManager from '@dxos/app-framework/PluginManager'; import * as UrlLoader from '@dxos/app-framework/UrlLoader'; ``` The plugin manager holds an app's plugins, decides which are enabled, and activates their modules as events fire. An app such as Composer creates one at boot; a plugin reaches it through `Capabilities.PluginManager` when it needs to turn other plugins on or off. [Activation](/docs/plex/concepts/activation/) explains the order it activates modules in. ## Create a plugin manager `PluginManager.make` takes the plugins the app knows about and the ones enabled at start. Plugins tagged `system` are always enabled. The `pluginLoader` turns an id the manager is later asked to add into a plugin. ```ts import * as PluginManager from '@dxos/app-framework/PluginManager'; import * as UrlLoader from '@dxos/app-framework/UrlLoader'; import { make } from './dx.config.ts'; const stash = make(); export const manager = PluginManager.make({ plugins: [stash], enabled: [stash.meta.profile.key], // Turns an id `add` is given into a plugin; this one loads manifest URLs. pluginLoader: UrlLoader.make([]), }); ``` `start` runs the startup pass: it activates the Startup modules and every module whose requirements they satisfy. An app fires `Idle` once it has painted. A React app renders `App.Root` from `@dxos/app-framework/App`, which does both. ```ts import * as Effect from 'effect/Effect'; import * as ActivationEvents from '@dxos/app-framework/ActivationEvents'; export const start = Effect.gen(function* () { yield* manager.start(); yield* manager.activate(ActivationEvents.Idle); }); ``` ## Enable, disable and remove plugins `enable` turns on a plugin and the plugins it declares in `dependsOn`, then activates every module whose event has fired. `disable` deactivates a plugin's modules and, by default, the plugins that depend on it. `remove` disables a plugin and forgets it. ```ts import * as Effect from 'effect/Effect'; export const toggle = (key: string) => Effect.gen(function* () { if (manager.getEnabled().includes(key)) { // Also disables the plugins that depend on it. yield* manager.disable(key); } else { yield* manager.enable(key); } }); ``` ## Load a plugin from a URL `add` hands an id to the plugin loader and registers the result without enabling it. `UrlLoader.make` loads a plugin from its manifest URL, which is how Composer's Load dev plugin and the docs' [inspector](/docs/plex/guides/debugging/) load a plugin from its dev server. The page needs an import map of the shared packages, so the plugin uses the page's copies. ```ts import * as Effect from 'effect/Effect'; export const loadDevPlugin = Effect.gen(function* () { // The plugin's own key may differ from the URL it was loaded from. const plugin = yield* manager.add('http://localhost:3967/manifest.json'); yield* manager.enable(plugin.meta.profile.key); }); ``` ## Read which plugins and modules are active Each piece of state has a getter for a one-off read and an atom for a component that should update. `getFailed` lists plugins that failed to load or activate, with the phase and the error. ```ts export const report = () => ({ enabled: manager.getEnabled(), activeModules: manager.getActive(), failed: manager .getFailed() .map(({ id, phase, reason }) => ({ id, phase, reason })), }); // The same state as atoms, for a component that should update. export const enabledAtom = manager.enabled; ``` ## Members at a glance | Member | What it does | | --- | --- | | `make(options)` | Creates a manager. | | `start()`, `activate(event)` | Runs startup; fires an event. | | `add(id)`, `enable(id)`, `disable(id)`, `remove(id)` | Changes which plugins are loaded and on. | | `plugins`, `enabled`, `active`, `failed` | Atoms of the manager's state; each has a `get…` reader. | | `getDependencies(id)`, `getDependents(id)` | The plugins a plugin needs, or that need it. | | `capabilities` | The capability manager that holds every contribution. | ## Related | Page | What it covers | | --- | --- | | [Activation](/docs/plex/concepts/activation/) | When the manager activates each module. | | [Plugin](/docs/plex/guides/plugin/) | Defining the plugins the manager holds. | | [Testing a plugin](/docs/plex/guides/testing/) | A manager in a headless test app. | --- # Operation Define operations, implement their handlers and invoke them. ```ts import * as Operation from '@dxos/compute/Operation'; import * as OperationHandlerSet from '@dxos/compute/OperationHandlerSet'; ``` An operation is a named action with a typed input and output. [Operations](/docs/plex/concepts/operations/) explains why the UI and agents share them. ## Define an operation with input and output schema `Operation.make` takes a key, metadata and two schemas. Annotations such as `Operation.mutation` describe the operation's effect, which tools show before running it. ```ts import * as Schema from 'effect/Schema'; import * as Operation from '@dxos/compute/Operation'; import { DXN } from '@dxos/echo'; export const Slugify = Operation.make({ meta: { key: DXN.make('com.example.operation.text.slugify'), name: 'Slugify', description: 'Turn a title into a URL slug.', }, input: Schema.Struct({ title: Schema.String }), output: Schema.Struct({ slug: Schema.String }), }).pipe(Operation.mutation('none')); ``` ## Implement handlers and group them `Operation.withHandler` attaches an Effect to a definition. ```ts import * as Effect from 'effect/Effect'; import * as Operation from '@dxos/compute/Operation'; export const SlugifyHandler = Slugify.pipe( Operation.withHandler(({ title }) => Effect.succeed({ slug: title .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, '-'), }), ), ); ``` `OperationHandlerSet.make` groups handlers that are already loaded. `OperationHandlerSet.lazy` takes lazy handlers, whose modules load the first time each operation runs. Contribute the set through `Capabilities.OperationHandler`. ```ts import * as Operation from '@dxos/compute/Operation'; import * as OperationHandlerSet from '@dxos/compute/OperationHandlerSet'; export const TextHandlers = OperationHandlerSet.make(SlugifyHandler); // Lazy: the handler's module loads the first time the operation runs. export const LazyTextHandlers = OperationHandlerSet.lazy([ Slugify.pipe( Operation.lazyHandler(async () => ({ default: SlugifyHandler })), ), ]); ``` ## Invoke an operation and handle its typed errors `Operation.invoke` returns an Effect that needs the operation service, which the framework provides to modules, handlers and graph actions. It fails with `NoHandlerError` when no plugin handles the operation. ```ts import * as Effect from 'effect/Effect'; import * as Operation from '@dxos/compute/Operation'; export const slugOf = (title: string) => Operation.invoke(Slugify, { title }).pipe( Effect.map(({ slug }) => slug), Effect.catchTag('NoHandlerError', () => Effect.succeed(title)), ); ``` From React, use `useOperation` or `useOperationInvoker`. See [React](/docs/plex/guides/react/). ## The built-in layout operations `LayoutOperation` from `@dxos/app-toolkit` changes what is on screen. `Open` and `Close` take node paths; `Set` replaces the open items; `ScrollIntoView`, `UpdateSidebar`, `UpdateDialog` and `AddToast` do what their names say. ```ts import * as LayoutOperation from '@dxos/app-toolkit/LayoutOperation'; import * as Operation from '@dxos/compute/Operation'; export const openObject = (path: string) => Operation.invoke(LayoutOperation.Open, { subject: [path] }); ``` ## Related | Page | What it covers | | --- | --- | | [Add an operation](/docs/plex/learn/add-operation/) | An operation end to end, including the assistant. | | [App Graph](/docs/plex/guides/app-graph/) | Attaching an operation to nodes as an action. | --- # Surface Contribute surfaces and render other plugins' surfaces. ```ts import * as Surface from '@dxos/app-framework/Surface'; import * as AppSurface from '@dxos/app-toolkit/AppSurface'; ``` `Surface` creates surface contributions and renders surfaces. `AppSurface` holds the App Toolkit's roles and the filters that match them. [Surfaces](/docs/plex/concepts/surfaces/) explains how matching works. ## Contribute a surface for an object type `Surface.create` takes an id, a filter, a component and a `props` mapper. `AppSurface.object(role, Type)` matches a role when the data's subject is an object of the type. ```ts import * as Surface from '@dxos/app-framework/Surface'; import * as AppSurface from '@dxos/app-toolkit/AppSurface'; export const NoteArticleSurface = Surface.create({ id: 'article', // Matches the article role when the data's subject is a Note. filter: AppSurface.object(AppSurface.Article, Note), component: NoteArticle, // Maps the surface's data onto the component's props; checked against the // filter's data type. props: ({ role, data: { subject, attendableId } }) => ({ role, subject, attendableId, }), }); ``` Contribute surfaces from a module through `Capabilities.ReactSurface`. In a manifest, `AppCapability.surface` makes that module and loads it when one of its `roles` is first requested. ```ts import * as Effect from 'effect/Effect'; import * as Capabilities from '@dxos/app-framework/Capabilities'; import * as Capability from '@dxos/app-framework/Capability'; export default Capability.makeModule(() => Effect.succeed( Capability.contribute(Capabilities.ReactSurface, [ NoteArticleSurface, NoteCardSurface, ]), ), ); ``` ## Match on data and set the order `AppSurface.oneOf` matches any of several filters, and `AppSurface.allOf` requires all of them. Other filters match a literal value (`AppSurface.literal`), the object a companion panel is attached to (`AppSurface.companion`), or any subject (`AppSurface.subject`). ```ts import * as Surface from '@dxos/app-framework/Surface'; import * as AppSurface from '@dxos/app-toolkit/AppSurface'; export const NoteCardSurface = Surface.create({ id: 'card', filter: AppSurface.oneOf( AppSurface.object(AppSurface.CardContent, Note), AppSurface.object(AppSurface.Section, Note), ), component: NoteCard, props: ({ data: { subject } }) => ({ subject }), }); ``` When several surfaces match, `position` on the contribution orders them. Pass `Position.first` or `Position.last` from `@dxos/util` to come before or after the rest. ## Render a nested surface from a component `Surface.Surface` renders whatever surface matches a role and data. Pass `limit` to cap how many render. ```ts import * as Surface from '@dxos/app-framework/Surface'; import * as AppSurface from '@dxos/app-toolkit/AppSurface'; // Renders whatever surface another plugin contributed for this object in the // section role. export const Embedded = ({ subject }: { subject: Note }) => ( ); ``` ## The standard roles | Role | Where it renders | Data | | --- | --- | --- | | `Article` | The main content area, one object per plank. | `{ subject, attendableId }` | | `Section` | An object inside a stack or another document. | `{ subject, attendableId }` | | `CardContent`, `CardIcon`, `CardMenu` | Parts of an object's card. | `{ subject }` | | `ObjectProperties` | The properties panel of an object. | `{ subject }` | | `Dialog`, `Popover` | A dialog or popover opened by an operation. | Set by the caller. | | `StatusIndicator` | The status bar. | None. | | `deckCompanion(variant)` | A workspace-wide panel beside the deck. | `{ id?, subject? }` | ## Related | Page | What it covers | | --- | --- | | [Add a surface](/docs/plex/learn/add-surface/) | A surface end to end. | | [React](/docs/plex/guides/react/) | Hooks a surface component uses. | --- # API reference Generated API reference for the PLEX packages. - `@dxos/app-framework`: plugins, modules, capabilities, activation events and the plugin manager. - `@dxos/app-toolkit`: the capabilities, makers and surfaces a workspace app adds. - `@dxos/app-graph`: App Graph, its nodes and its builder. - `@dxos/compute`: operations and layer specs. --- # Glossary Every Composer and PLEX term, defined once. ECHO terms, such as space and object, are in ECHO's [glossary](/docs/additional-resources/glossary/). Identity and invitation terms are in HALO's [glossary](/docs/halo/resources/glossary/). ### Action A [node](#node) in [App Graph](#app-graph) that stands for something a person can do to another node. Its data is a function that returns an Effect. The action's disposition says where it appears, such as an object's toolbar or its context menu. See [App Graph](/docs/plex/guides/app-graph/). ### Activation event An event that tells the [plugin manager](#plugin-manager) when to load a [module](#module), such as startup or the first time a view is needed. See [ActivationEvent](/docs/plex/guides/activation-event/). ### Annotation Metadata on an ECHO schema that tells the app how to present its objects, such as which property is the label, the type's icon, or whether people create it themselves. See [Using ECHO in a plugin](/docs/plex/guides/using-echo/). ### App Graph The graph of everything a person can open or do in an app, made of [nodes](#node) and [actions](#action). In Composer the navigation tree, toolbars, context menus and command palette all read it. See [App Graph](/docs/plex/guides/app-graph/). ### App Toolkit `@dxos/app-toolkit`, the package that adds what a workspace app needs on top of the framework: the [App Graph](#app-graph), layout, settings and the standard [roles](#role). See [App capabilities](/docs/plex/guides/app-capabilities/). ### Builder extension A matcher and a connector that add [nodes](#node) to [App Graph](#app-graph). The matcher picks the nodes it applies to, and the connector returns the nodes to connect to them. See [App Graph](/docs/plex/guides/app-graph/). ### Capability A typed slot that [modules](#module) contribute values to and other modules read. Plugins extend each other through capabilities instead of imports. See [Capability](/docs/plex/guides/capability/). ### Filter The part of a [surface](#surface) contribution that names the [roles](#role) it serves and a guard on the data, such as "the article role, when the subject is a Note". See [Surfaces](/docs/plex/concepts/surfaces/). ### Handler An Effect that takes an [operation](#operation)'s input and returns its output. Plugins contribute handlers in a handler set, separately from the operation's definition. See [Operation](/docs/plex/guides/operation/). ### Module A part of a [plugin](#plugin) that loads on an [activation event](#activation-event) and contributes [capabilities](#capability). See [Plugin](/docs/plex/guides/plugin/). ### Node An item in [App Graph](#app-graph), with an id, a type, data and properties. Its data is usually what it stands for, such as a space or an ECHO object. Each node has a path of ids from the root, and in Composer the URL is a node path. ### Operation A named action with a typed input and output, defined once and run by buttons, the command palette and agents alike. See [Operations](/docs/plex/concepts/operations/). ### Operation invoker The part of the framework that runs an [operation](#operation): it looks up the [handler](#handler) by the operation's key, validates the input and output, and runs the handler. ### Plugin Metadata and the [modules](#module) a plugin is made of. A plugin does nothing by itself; its modules contribute everything it adds to the app. See [Plugin](/docs/plex/guides/plugin/). ### Plugin manager The part of the framework that loads [plugins](#plugin), fires [activation events](#activation-event) and activates the [modules](#module) waiting on them. ### Role A named place in an app's UI, such as `Article` for the main content area or `Section` for an object inside a stack. Each role declares the data it passes to a [surface](#surface). See [Surfaces](/docs/plex/concepts/surfaces/). ### Schema registration A plugin's contribution to the `Schema` capability, which lists the ECHO types it defines. The app can name, list and create only objects of registered types. See [Using ECHO in a plugin](/docs/plex/guides/using-echo/). ### Surface A named place in the UI that plugins fill with components. A surface contribution is a component and a [filter](#filter); the host asks for a [role](#role) and renders the contributions that match. See [Surfaces](/docs/plex/concepts/surfaces/). --- # llms.txt The PLEX site map and full text for agents, in the llms.txt format. Point an agent at these files before asking it to work with PLEX. They follow the [llms.txt](https://llmstxt.org/) format and cover every written page of this section. | File | Contents | | -------------------------------------------- | ---------------------------------------------------------- | | [`/plex/llms.txt`](/plex/llms.txt) | Each PLEX page with its summary and a link to its Markdown | | [`/plex/llms-full.txt`](/plex/llms-full.txt) | Every PLEX page in one file | Any page is also served as Markdown at its URL with `.md` in place of the trailing slash.